Compare commits
79 Commits
b4cda76114
...
68bf328e7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 68bf328e7c | |||
| b5bd1c977b | |||
| 4b26e0a969 | |||
| c6078a3e71 | |||
| 0874042040 | |||
| 6d3b9cd4d3 | |||
| 9792d4346e | |||
| fc20a5d3d2 | |||
| f02974b3c2 | |||
| a6e565e445 | |||
| 38e345ccf7 | |||
| fd8c0e389f | |||
| b359786e69 | |||
| bef3f590ca | |||
| 407ed90341 | |||
| 92a3bea129 | |||
| 6480953189 | |||
| b22c3f96d7 | |||
| 62620bc0d4 | |||
| 55b26b2e41 | |||
| 508a522a8d | |||
| cf557e16aa | |||
| a2f9742f8a | |||
| a29b961c27 | |||
| e077b8ec7b | |||
| 612b21b1e7 | |||
| 70d4a87cd5 | |||
| ae531116b7 | |||
| 63bdc5ee93 | |||
| f767d288c5 | |||
| 9d7f2ff003 | |||
| c59f59c3fe | |||
| 91566692f6 | |||
| 16f356a760 | |||
| 8983592e56 | |||
| 92ddc5bb3e | |||
| 76e5080278 | |||
| 675710d086 | |||
| c46c3a2f9c | |||
| 49e99ff986 | |||
| 5a345cabea | |||
| 25ade16b07 | |||
| 5d9ba1c953 | |||
| ab418bf840 | |||
| d3f1d6a8a0 | |||
| 4d9505c341 | |||
| 0439d3da4f | |||
| 98142754fa | |||
| 3da12067f6 | |||
| 86e1243eba | |||
| b6b212e429 | |||
| 879c30a5e5 | |||
| a2771c71aa | |||
| 81b8796ba5 | |||
| 489215e415 | |||
| b7b5933b25 | |||
| c4930e80ba | |||
| b04081b960 | |||
| bd6bd4d827 | |||
| c835a54652 | |||
| 909d259df9 | |||
| f10e20a0e2 | |||
| 009f565b73 | |||
| 4a46ec36b3 | |||
| 0b0bcb3dee | |||
| 34e7f2f8ed | |||
| 3bb8104967 | |||
| a82bd875d9 | |||
| 72171c9374 | |||
| 480c961a09 | |||
| 754dc311a6 | |||
| d47a5e00af | |||
| 77dee5eac5 | |||
| f8186fb7c7 | |||
| 092ac0b5f2 | |||
| 3953229ae4 | |||
| 8d80d43a47 | |||
| eddbb00cd9 | |||
| aa1f7d50f1 |
@@ -6,6 +6,436 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — CMS Track Browser
|
||||
|
||||
### 8.6 "Music through Every Medium" home page section
|
||||
|
||||
**Landed:** 2026-06-12 on dev.
|
||||
|
||||
Replaces the "Genres & Moods" block in `DeepDrftPublic.Client/Pages/Home.razor` (lines 43–86 — the `<section class="section">` containing the `.genre-grid`). The 6 text-only genre cards become **3 image-first cards** keyed on release format: Studio, Live, DJ Mix. The pivot is taxonomy → medium: instead of "what scene is this," the section answers "in what form does the music reach you."
|
||||
|
||||
The section-divider tag stays "The Sound." The `.section-divider` and `.section-header-grid` wrappers are **untouched** — only the header copy inside the grid and the card grid below it change. Everything from `.section-dark` onward is untouched.
|
||||
|
||||
**Design intent.** The current section is a flat, typographic palette grid — appropriate when the message was "we span many genres." The new message is fewer, weightier, photographic: three distinct *ways* the collective produces, each earning a full image pane. This trades the dense 6-up rhythm for a confident 3-up editorial spread, closer in spirit to the dark `.features-grid` (icon + title + desc) but image-led rather than icon-led. The card is the unit of interest now, not the grid texture.
|
||||
|
||||
### 1. Section header copy
|
||||
|
||||
| Slot | Class | Copy |
|
||||
| --- | --- | --- |
|
||||
| Label | `.section-label` | `Format & Medium` |
|
||||
| Title | `.section-title` | `Music through<br /><em>Every</em><br />Medium` |
|
||||
| Body | `.section-body` | `The same hands, three different rooms. A studio cut is built; a live set is risked; a DJ mix is woven. We release in every form the music asks for — each one a different relationship between the moment and the record of it.` |
|
||||
|
||||
The `<em>Every</em>` carries the italic-green emphasis the existing `.section-title em` rule already styles — no change needed there. (Title echoes the prior "Every Frequency Explored" cadence deliberately, so the replacement reads as an evolution of the same voice, not a rewrite.)
|
||||
|
||||
### 2. Card copy
|
||||
|
||||
| Card | Type label (`.medium-type`, mono) | Title (`.medium-name`, serif) | One-line descriptor (`.medium-desc`) |
|
||||
| --- | --- | --- | --- |
|
||||
| Studio | `Studio` | `Studio Releases` | `Composed, layered, and finished — tracks built to be returned to.` |
|
||||
| Live | `Live` | `Live Releases` | `Performances caught in the moment, unrepeatable and unedited.` |
|
||||
| DJ Mix | `Mix` | `DJ Mix Releases` | `Uninterrupted sets — one track bleeding into the next, start to finish.` |
|
||||
|
||||
The type labels (`Studio` / `Live` / `Mix`) play the same one-word-essence role the genre `.genre-count` labels did ("Foundation," "Architecture," …) — kept deliberately to preserve that tic of the original design.
|
||||
|
||||
### 3. HTML structure sketch
|
||||
|
||||
Replaces Home.razor lines 43–86. Header grid block keeps its existing structure with only the copy swapped; the grid below is new:
|
||||
|
||||
```razor
|
||||
@* Medium section *@
|
||||
<section class="section">
|
||||
<div class="section-header-grid">
|
||||
<MudGrid Style="margin-bottom: 5rem;">
|
||||
<MudItem xs="12" md="4">
|
||||
<div class="section-label">Format & Medium</div>
|
||||
<h2 class="section-title">Music through<br /><em>Every</em><br />Medium</h2>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="8">
|
||||
<p class="section-body"> ...body copy from §1... </p>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</div>
|
||||
|
||||
<div class="medium-grid">
|
||||
@* TODO Phase 3.x: wire each card to its format-filtered browse route once /tracks?format= exists *@
|
||||
<div class="medium-card">
|
||||
<div class="medium-image" style="background-image: url('img/dd-studio.jpg');">
|
||||
<div class="medium-scrim"></div>
|
||||
</div>
|
||||
<div class="medium-body">
|
||||
<div class="medium-type">Studio</div>
|
||||
<div class="medium-name">Studio Releases</div>
|
||||
<div class="medium-desc">Composed, layered, and finished — tracks built to be returned to.</div>
|
||||
</div>
|
||||
</div>
|
||||
@* …Live card (dd-live.jpeg) and DJ Mix card (dd-dj.jpeg) follow the same shape… *@
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
Notes for the implementer:
|
||||
- **Image as CSS `background-image`, not `<img>`.** This makes `cover`-cropping, the scrim overlay, and the hover scale trivial without a wrapper-overflow dance, and keeps these decorative-but-branded photos out of the document's content image flow. (If alt-text/SEO is later wanted, revisit — but these are mood images, not informational, so background is the right call here.) The card is one block: image pane on top, text body below, matching the brief's "image area + text below."
|
||||
- The three cards are structurally identical — implementer can author one and repeat. Leave the `TODO` comment so the future format-filter routing has a home (mirrors the existing `@* TODO Phase 2.2 *@` convention in the current genre grid).
|
||||
- Whether the card is a `<div>` or an `<a>` is deferred: there is no format-filtered route yet (the genre grid had the same unresolved `/genres/{slug}` TODO). Author as `<div>` now; the `.medium-card` hover styles already assume `cursor` affordance so promoting to `<a>` later is a one-line change.
|
||||
|
||||
### 4. CSS additions (`Home.razor.css`)
|
||||
|
||||
Added a new block after the (now-removed) genre-grid rules. New classes:
|
||||
|
||||
```css
|
||||
/* ── MEDIUM GRID (Music through Every Medium) ── */
|
||||
.medium-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--deepdrft-border);
|
||||
border: 1px solid var(--deepdrft-border);
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.medium-card {
|
||||
background: var(--deepdrft-white); /* fixed white ground — matches .section, see §9 */
|
||||
cursor: pointer;
|
||||
overflow: hidden; /* clips the hover image scale */
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.medium-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3; /* consistent crop across all three; ~240px tall at 1-col, scales with column width */
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.medium-card:hover .medium-image { transform: scale(1.05); }
|
||||
|
||||
.medium-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(17, 35, 56, 0.0) 40%,
|
||||
rgba(17, 35, 56, 0.35) 100%); /* navy scrim, weighted to the lower edge near the text seam */
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.medium-card:hover .medium-scrim { opacity: 1; }
|
||||
|
||||
.medium-body {
|
||||
padding: 2rem 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Green underline sweep — same mechanic as the old .genre-card::after */
|
||||
.medium-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: var(--deepdrft-green-accent);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.3s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.medium-card:hover::after { transform: scaleX(1); }
|
||||
|
||||
.medium-type {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--deepdrft-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.medium-name {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.medium-desc {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.65;
|
||||
color: var(--deepdrft-navy);
|
||||
opacity: 0.6;
|
||||
}
|
||||
```
|
||||
|
||||
Reuse decisions:
|
||||
- `.section`, `.section-divider`, `.section-header-grid`, `.section-label`, `.section-title`, `.section-body` — all reused unchanged.
|
||||
- `.medium-type` / `.medium-name` / `.medium-desc` are new but are deliberate near-clones of `.genre-count` / `.genre-name` (bumped from 1.5→1.6rem to suit the larger card) / a new descriptor line the genre cards never had. Kept as distinct classes rather than reusing the `.genre-*` names so the dead genre CSS can be removed cleanly.
|
||||
- The underline-sweep `::after` is copied from `.genre-card::after` verbatim except for the added `z-index: 1` (needed because the card now has a stacking context from the image).
|
||||
|
||||
### 5. Responsive breakpoints
|
||||
|
||||
| Viewport | `.medium-grid` columns | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| ≥ 960px | `repeat(3, 1fr)` | Three cards in a row — the primary editorial layout. |
|
||||
| 600–959px | `repeat(2, 1fr)` + third card spans both columns | Two on top, the third full-width below. Reads better than a lone 1-col orphan on tablet and keeps the image panes generous. |
|
||||
| < 600px | `1fr` | Single column, cards stack. Each image pane is full content-width; `aspect-ratio: 4/3` keeps them generous (~260px tall at a typical mobile width). |
|
||||
|
||||
```css
|
||||
@media (max-width: 959px) {
|
||||
.medium-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.medium-card:last-child { grid-column: 1 / -1; } /* third card spans full width */
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.medium-grid { grid-template-columns: 1fr; }
|
||||
.medium-card:last-child { grid-column: auto; } /* reset the span at 1-col */
|
||||
}
|
||||
```
|
||||
|
||||
Note the breakpoint boundary is `959px` here (the existing genre grid used `960px` for its `max-width` query; `.section-header-grid` uses `min-width: 960px`). Using `max-width: 959px` avoids the 1px both-rules-fire overlap at exactly 960px. Implementer may keep `960` for consistency with the surrounding file if preferred — the `last-child` span makes the 960 edge case harmless either way.
|
||||
|
||||
### 6. Image placeholder names
|
||||
|
||||
All three in `DeepDrftPublic.Client/wwwroot/img/` (same dir as existing hero images), referenced as `img/<name>` to match the existing `Image1="img/..."` convention:
|
||||
|
||||
- `dd-studio.jpg`
|
||||
- `dd-live.jpeg`
|
||||
- `dd-dj.jpeg`
|
||||
|
||||
File extensions match existing photos on the page (`dd-duo-hero.jpeg`, `kp-shoulder-bw.jpeg`). Recommend source images at least 800px wide (rendered up to ~430px wide at the 3-col desktop layout on a 1440px viewport, so 800px covers 2× displays). Consistent landscape orientation across all three — the `4/3 aspect-ratio` crop will center-cover whatever is supplied, but landscape sources avoid heavy cropping.
|
||||
|
||||
### 7. Hover and overlay spec
|
||||
|
||||
- **Underline sweep** (preserved from genre cards): on `:hover`, a 2px green-accent bar sweeps in from the left along the card's bottom edge (`scaleX(0)→(1)`, 0.3s). Unchanged mechanic.
|
||||
- **Image scale** (new, additive): on `:hover`, the background image scales to `1.05` over 0.5s, clipped by the card's `overflow: hidden`. Slow and subtle — a breath, not a zoom. This is the "parallax-scale" the brief allowed; pure CSS transform, no JS.
|
||||
- **Scrim** (always-on, subtle): a navy gradient (`--deepdrft-navy` at 0%→35% alpha, top→bottom) sits over the image at `opacity: 0.7`, deepening to `1.0` on hover. Two jobs: (a) it weights the image toward its lower edge so the transition into the text body feels intentional rather than abrupt, and (b) it future-proofs for overlaying white text on the image if a later iteration wants the title *on* the photo. Today all text sits in `.medium-body` below the image, so the scrim is purely tonal — keep it light; it should never read as a dark box. If during implementation the supplied photos are already dark/low-key, dial the base opacity down to `0.4` rather than fighting them.
|
||||
|
||||
The hover bundle (underline + scale + scrim-deepen) fires together as one gesture. Don't stagger them.
|
||||
|
||||
### 8. Dark-mode awareness
|
||||
|
||||
The raw `--deepdrft-white` and `--deepdrft-navy` tokens are **literal** in both themes — they are *not* remapped under `.deepdrft-theme-dark` (verified in `deepdrft-tokens.css`; only the alias tokens like `--deepdrft-surface`/`--theme-*` flip). The existing `.section` and `.genre-card` both hardcode `background: var(--deepdrft-white)`, so **this whole section is a fixed off-white ground in both light and dark mode today** — it does not invert.
|
||||
|
||||
The new `.medium-card` follows that same convention deliberately: white card ground, navy text, in both themes. This keeps Phase 8.6 consistent with its untouched siblings (`.section` above it stays white; only `.section-dark` below it is dark). **Do not** introduce theme-aware surface tokens here — that would make this one section invert while the rest of the white `.section` stays put, which is a larger and out-of-scope design decision (if Daniel wants the public home page to genuinely respond to dark mode, that is a separate roadmap item spanning every `.section`, not a Phase 8.6 concern).
|
||||
|
||||
- **Images:** unaffected by theme — same assets render identically. The navy scrim also reads correctly against the off-white card in both modes.
|
||||
- **Text & backgrounds:** `--deepdrft-navy` text on `--deepdrft-white` card in both modes. No `.deepdrft-theme-dark` overrides needed or wanted for this section.
|
||||
|
||||
### 9. Out of scope / deferred
|
||||
|
||||
- **Format-filtered routing.** Cards are non-navigating today (no `/tracks?format=` route exists). The `TODO` comment marks where it lands. This mirrors the genre grid's never-resolved `/genres/{slug}` TODO — don't build the route as part of 8.6.
|
||||
- **A real format field on `TrackEntity`.** "Studio / Live / DJ Mix" is presentational copy here, not yet a data dimension. If these cards are ever to filter real tracks, the entity needs a `Format`/`ReleaseType` discriminator — that is Phase 3 (new content kinds) territory, not this cosmetic swap. Flagging so the copy isn't mistaken for an existing capability.
|
||||
|
||||
**Completion note:** "Genres & Moods" genre-card grid on home page replaced with "Music through Every Medium" 3-card section (Studio Releases, Live Releases, DJ Mix Releases), each image-led with background image, scrim overlay, hover scale+underline animations. Dead `.genre-*` CSS rules removed from `Home.razor.css`. New `.medium-*` CSS block added with responsive grid (3 cards at md+, 2-up at sm, single column at xs). Type labels corrected to `Studio / Live / Mix` (final decision superseding earlier spec). Three images (`dd-studio.jpg`, `dd-live.jpeg`, `dd-dj.jpeg`) added to `wwwroot/img/`.
|
||||
|
||||
---
|
||||
|
||||
### 8.7 CMS upload cache invalidation + "Releases" label rename (Wave 7)
|
||||
|
||||
**Landed:** 2026-06-12 on dev.
|
||||
|
||||
Two small runtime-discovered bug fixes in the Phase 8 CMS track browser:
|
||||
|
||||
1. **Upload cache invalidation** — After a successful track upload in `BatchUpload.razor` or `TrackNew.razor`, the code now injects `CmsTrackBrowserViewModel` and calls `VM.Invalidate()` before navigating away. This ensures the Releases tab (in Album and Genre browse modes) always shows fresh data and does not display stale cached lists from before the upload. Fixes the issue where newly uploaded tracks would not appear in album/genre browse until a manual refresh.
|
||||
|
||||
2. **"Albums" → "Releases" label rename** — The toggle tab label in `TrackList.razor` and the summary card label in the CMS home page (`Index.razor`) were renamed from "Albums" to "Releases". This better reflects the actual content — releases encompassing all release types (studio, live, DJ mix), not just albums. Improves terminology consistency with the normalized `ReleaseEntity` and the Phase 8 UI.
|
||||
|
||||
**Completion note:** `BatchUpload.razor` and `TrackNew.razor` updated to invalidate `CmsTrackBrowserViewModel` cache on successful upload. `TrackList.razor` toggle tab label and `Index.razor` card label changed from "Albums" to "Releases" for terminology consistency.
|
||||
|
||||
---
|
||||
|
||||
### 8.6 CMS cache invalidation + orphaned release deletion (Wave 6)
|
||||
|
||||
**Landed:** 2026-06-12 on dev.
|
||||
|
||||
Three linked CMS bug fixes discovered during Phase 8 browser work:
|
||||
|
||||
1. **Cache invalidation on mutations** — Added `CmsTrackBrowserViewModel.Invalidate()` method called from `TrackEdit`, `BatchEdit`, and `TrackList.OnAlbumsChanged` after any track/release mutation. Ensures the album/genre browse cache is never stale when tracks are added, edited, or deleted.
|
||||
|
||||
2. **Orphaned release handling** — `CmsAlbumBrowser` now handles 0-track (orphaned) releases with a confirmation dialog + `DeleteReleaseAsync` via a new `DELETE api/track/release/{id}` endpoint. Partial-failure album-delete path also invalidates the cache. Admin can now clean up releases that have lost all their tracks.
|
||||
|
||||
3. **Cascade-delete on last-track removal** — EF migration `SoftDeleteOrphanedReleases` (data-only, raw SQL) backfills orphaned release rows with soft-delete markers. `UnifiedTrackService.DeleteAsync` now cascades a release soft-delete when the last live track in a release is deleted (non-fatal; orphaned releases do not block track deletion).
|
||||
|
||||
**Completion note:** `CmsTrackBrowserViewModel.Invalidate()` added and wired into mutation paths. New `DELETE api/track/release/{id}` endpoint implemented on `UnifiedTrackService`. `CmsAlbumBrowser` updated with orphaned release confirmation + delete. `SoftDeleteOrphanedReleases` migration authored and applied. All three fixes integrated; Phase 8 browse modes remain stable with correct cache coherence and release cleanup semantics.
|
||||
|
||||
---
|
||||
|
||||
### 8.0 `TrackEntity` normalization
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** Split the flat `TrackEntity` into two normalized tables. New **`ReleaseEntity`** holds release-cardinal data (`Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `CreatedByUserId?`). Slimmed **`TrackEntity`** holds track-cardinal data only (`Id`, `ReleaseId` FK, `Release` nav, `EntryKey`, `TrackName`, `TrackNumber`, `OriginalFileName?`) — the release fields are removed from it. New `ReleaseDto`; `TrackDto` slims and gains `ReleaseId` + a **nested `Release` (`ReleaseDto`)** (resolved 2026-06-11: nested, not a flat read model — flat fields are removed and every consumer is updated, not denormalized back); `AlbumSummaryDto` is retired in favour of `ReleaseDto`.
|
||||
- **Why:** The flat schema duplicates release-level metadata on every track row — updating an album's cover art or artist means rewriting every track. Phase 8 introduces album-as-a-unit editing (Batch Edit, album-scoped delete), so the model should match the domain: a Release is first-class; Tracks belong to a Release. This collapses several §8 UI open questions (Album-mode parent rows become Release rows directly; no `GROUP BY`-derived summary).
|
||||
- **Shape:** Sequenced as **five mergeable waves** (notes §0.6): (1) data model — `ReleaseEntity`/config/migration in `DeepDrftData`; (2) DTOs/services/repositories/API — `ReleaseDto`, slimmed `TrackDto`, JOIN-projecting repository, upload find-or-create Release; **Waves 1 + 2 are a single deployment unit** (removing the entity fields breaks compile until the DTO/service layer lands — never merge Wave 1 alone); (3) public-client consumers (`TrackCard`, `TrackDetail`, `TrackMetaLabel`, `NowPlayingCard`) re-point to `track.Release.*`; (4) existing CMS surfaces (`TrackEdit`, `TrackNew`, `BatchUpload`, `TrackList`) minimally updated to compile on the normalized model — Waves 3 + 4 run in parallel; (5) the Phase 8 UI (§8.1–§8.5) begins only after 1–4 are stable. The breaking migration: create `releases`, populate from distinct `(album, artist)` groups, add + populate `release_id` FK, drop redundant track columns. Remaining open questions for Daniel: nullable release FK for album-less tracks (recommend yes), upload auto-create-or-find Release (recommend yes — committed in Wave 2 shape). Full spec, wave breakdown, and per-file consumer list: notes §0 / §0.6.
|
||||
|
||||
**Completion note:** `ReleaseEntity` table and EF configuration implemented in `DeepDrftData`. Two EF migrations landed (`NormalizeReleaseTrack` and `AddReleaseUniqueTitleArtist`) with full data migration backfill. `ReleaseDto` and slimmed `TrackDto` (with nested `Release` property) implemented in `DeepDrftModels`. Repository updated with JOIN-projecting queries. API controllers updated to return nested DTOs. Public-client consumers (`TrackCard`, `TrackDetail`, `NowPlayingCard`) and CMS surfaces (`TrackEdit`, `TrackNew`, `BatchUpload`, `TrackList`) all updated to point to `track.Release.*` fields. All five waves complete and merged to dev. Build clean, 155 tests pass. §8.1–§8.5 now unblocked.
|
||||
|
||||
### 8.1 URL scheme + mode toggle
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** `/tracks` (Track mode, default), `/tracks/albums`, `/tracks/genres` as route segments; a toggle inside the existing "Tracks" tab switches mode and pushes the matching URL. The Waveform Pre-Processing tab is untouched.
|
||||
- **Why:** The public home page hard-codes these as cross-host deep-links; a route segment reads as a stable address and matches the app's existing segment-based routing (`/tracks/upload`, `/tracks/{id}`). Query-param mode (`?mode=`) was the alternative — rejected as transient-looking view state, optionally tolerated as an alias.
|
||||
- **Shape:** One `TrackList` component carrying three `@page` directives (or three thin wrappers passing an `InitialMode`); the toggle drives `Mode` + `NavigationManager.NavigateTo`. See notes §3, §9.
|
||||
|
||||
**Completion note:** `TrackList.razor` refactored to support three route modes via `@page` directives (Track/Album/Genre). Mode-toggle control added to the UI, wired to `NavigationManager.NavigateTo` to push the matching URL. Toggle persists selection across navigation.
|
||||
|
||||
### 8.2 `CmsTrackGrid` — the reusable flat track table (DRY core)
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** Extract today's `MudTable<TrackDto>` into a standalone `CmsTrackGrid.razor` taking `AlbumFilter`/`GenreFilter` params. Apply the new column layout: Track # → 40×40 art thumb → Track Name → Artist → Album → Genre → Release Date (`d MMMM, yyyy`) → **Waveform Status** → Actions. Entry Key + File Name move out of the grid into an Info-icon tooltip (monospace). Art thumb reuses the public `TrackCard` fallback pattern, defined locally CMS-side.
|
||||
- **Why:** Single source of truth for the track-table layout — consumed by both Track mode (no filter) and Genre mode (genre filter), so no duplicated table markup. Decluttering Entry Key / File Name into a tooltip keeps the grid scannable while the data stays reachable. The Waveform column replaces the removed Waveform Pre-Processing tab (status visible inline; per-row Generate when no profile; page-level "Generate All Missing" in the Track-mode header).
|
||||
- **Shape:** Owns its own `MudTable` + `LoadServerData` + delete-confirm (lifted from `TrackList`). `GetPagedAsync` gains optional `album`/`genre` filter params — the one filter data-contract change (the endpoint already supports the filters); post-§0 the filter joins through `releases`. Waveform status comes from a new `HasWaveformProfile` bool on `TrackDto` (recommended over a second per-page lookup; fold into the §8.0 DTO pass). Display date format is presentation-only; sort key stays the raw `DateOnly`. See notes §8, §9, §11.
|
||||
|
||||
**Completion note:** New `CmsTrackGrid.razor` component implemented with full table layout (Track #, art thumb, name, artist, album, genre, release date, waveform status, actions). `ICmsTrackService.GetPagedAsync` extended with optional `album` and `genre` filter parameters. `HasWaveformProfile` bool added to `TrackDto`. Waveform status column displays profile state; per-row and page-level Generate actions wired. Info tooltip displays Entry Key and File Name. Grid consumed by Track mode (no filter) and Genre mode (genre filter); single source of truth for table markup.
|
||||
|
||||
### 8.3 Album mode
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** `CmsAlbumBrowser` — parent release rows (art, title, artist, track count, genre, release date, release-type chip, Edit + Delete) that expand to child track rows (track # + name only). Edit → Batch Edit page (§8.5); Delete → album-scoped delete of every track.
|
||||
- **Why:** A scannable release catalogue is the CMS analogue of the public `AlbumsView`, and the natural place to manage a release as a unit.
|
||||
- **Shape:** Post-§0, parent rows are `ReleaseEntity`/`ReleaseDto` rows — `GetReleasesAsync` (eager, once) supplies title/artist/genre/date/type directly, no derivation. Child tracks lazy via `GetPagedAsync(album:)` (joins through `releases`) on first expand, cached per row — no new endpoint. Expandable `MudTable` over `MudTreeView` (parent rows are multi-column, not tree-shaped). **The old `AlbumSummaryDto` widening question is dissolved by §8.0 normalization** — the Release table has all the fields, so the parent row is fully populated at rest with no DTO widening and no lazy derivation. See notes §6, §10, §0.5.
|
||||
|
||||
**Completion note:** New `CmsAlbumBrowser.razor` component implemented as an expandable release-row browser. Parent rows display `ReleaseDto` data (art, title, artist, track count, genre, release date, release-type chip). Child tracks loaded lazily on expand via `GetPagedAsync(album:)`, cached per row. Edit action navigates to Batch Edit page; Delete action removes album and all its tracks with confirmation. Leverages normalized `ReleaseEntity` from §8.0 — Release rows are fully populated at rest, no lazy derivation required.
|
||||
|
||||
### 8.4 Genre mode
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** `CmsGenreBrowser` — a responsive `MudCard` grid (one card per genre: name + track count); clicking a card expands it (accordion, one open at a time) to reveal a `CmsTrackGrid` filtered to that genre.
|
||||
- **Why:** CMS analogue of the public `GenresView`; the card-to-grid expand is the cheapest second mode because the grid is already built (§8.2).
|
||||
- **Shape:** `GetGenreSummariesAsync` once; the expanded panel renders `CmsTrackGrid` with `GenreFilter` set and the Add button suppressed — zero duplicated table markup. The embedded grid gets the waveform status column + per-row generate for free. See notes §7, §9.
|
||||
|
||||
**Completion note:** New `CmsGenreBrowser.razor` component implemented as a responsive card-grid accordion. Each card displays genre name and track count. Clicking a card expands it to reveal `CmsTrackGrid` filtered to that genre (Add button suppressed). One card open at a time. Grid embedded within each expanded panel inherits waveform status column and per-row generate actions. Zero duplicated table markup — consumes the single `CmsTrackGrid` source built in §8.2.
|
||||
|
||||
### 8.5 Batch Edit page
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** New page `/tracks/album/{albumName}/edit`, reached from an Album-mode row's Edit action. `BatchUpload`'s master-detail mechanics with the release's data preloaded; submit swaps per-row `UploadTrackAsync` for `UpdateAsync` on existing tracks (new tracks still upload). Distinct from the existing single-track edit at `/tracks/{id}`.
|
||||
- **Why:** Editing a release as a unit (rename tracks, reorder, swap cover, add tracks) without round-tripping the single-track editor per track.
|
||||
- **Shape:** **Confirmed:** a *new* `BatchEdit.razor` sharing extracted sub-components with `BatchUpload` — album-header fields block (post-§0 edits the `ReleaseDto`), batch track list (move-up/down/remove + status chips), track detail pane — over growing `BatchUpload` with an `isEdit` flag (the flag breeds conditional soup across preload/detail/submit). Cover art uses the established upload-once-then-link-via-`UpdateAsync` two-step. **Open:** does remove-in-edit delete an existing track (with confirm) or just detach? See notes §10, §12(8).
|
||||
|
||||
**Completion note:** New `BatchEdit.razor` page implemented at `/tracks/album/{releaseName}/edit`. Shares extracted sub-components with `BatchUpload`: `AlbumHeaderFields`, `BatchTrackList`, `BatchTrackDetail`, `BatchRowModel`. Two-panel layout with release-header block (album name, artist, genre, release date, cover art, release type) and left queue + right detail sections. Submit path swaps per-row `UploadTrackAsync` for `UpdateAsync` on existing tracks; new tracks still upload. Cover art uploaded once, linked via `UpdateAsync`. Remove-in-edit deletes existing track with confirmation. Reusable sub-components extracted for consistency across `BatchUpload` and `BatchEdit`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1.2 — Audio format diversity
|
||||
|
||||
**Landed:** 2026-06-11 on dev (all three waves complete).
|
||||
|
||||
- **What:** Today `AudioProcessor`, `WavOffsetService`, and the JS decoder are PCM/WAV-only. `MimeTypeExtensions` already 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.ProcessWavFileAsync` with a format-router that selects a per-format processor; replace `WavOffsetService` with 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 `decodeAudioData` over the full payload (loses streaming-start). The richer path is per-format chunked decoders. Worth a design pass before committing.
|
||||
- **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 `AudioBuffer`s through `decodeAudioData`. If a future path uses `MediaElementAudioSourceNode` (see 4.1), the FFT tap still works but the early-playback story changes.
|
||||
|
||||
**Completion note:** Fully landed across three waves on 2026-06-11. Server upload now accepts .wav/.mp3/.flac via `AudioProcessorRouter`. Client `StreamDecoder` is format-agnostic; `Mp3FormatDecoder` and `FlacFormatDecoder` provide chunked streaming with frame-boundary alignment and seek. Factory routing in `AudioPlayer.createFormatDecoder` selects decoder by Content-Type.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Shared UI Components
|
||||
|
||||
### 7.1 ParallaxImage component
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** A thin viewport-height container that reveals different portions of an image as the user scrolls — the classic CSS parallax window. As the window scrolls up through the viewport, the image pans through it faster than the page scrolls (top of image on entry, bottom of image by the time the window reaches the top of the viewport). An optional second image crossfades in on hover (intended use: grayscale at rest, colour on hover). A critical `FullWidth` flag stretches the window to `100vw`, breaking out of parent padding. Full signature and design in `product-notes/parallax-image-component.md`.
|
||||
- **Why it matters:** A reusable scroll flourish for hero/section surfaces on both the public site and the CMS, landing the visual identity work without bespoke per-page CSS. It is the first genuinely shared presentational component in `DeepDrftShared.Client` — establishes the pattern (and the RCL static-asset JS-module seam) for shared UI that both hosts consume.
|
||||
- **Shape:** `ParallaxImage.razor` (+ `.razor.cs`, `.razor.css`) in `DeepDrftShared.Client/Components/`. Scroll-driven `background-position` (never `background-attachment: fixed` — broken on iOS Safari), gated by an `IntersectionObserver` so off-screen instances cost nothing. Scroll math lives in a small JS module; lifecycle owned by Blazor via `ElementReference` + an imported `IJSObjectReference`, mirroring the existing audio interop seam. Crossfade is pure CSS. `IAsyncDisposable` tears down the listener. Full parameter table, parallax math, interop contract, full-width breakout technique, accessibility (reduced-motion, alt text), and edge cases (mobile Safari, preload timing) are specified in the product note.
|
||||
- **Prerequisite:** None functionally. Additive — no existing surface changes to adopt it.
|
||||
- **Constraint:** Both open decisions resolved (Daniel, 2026-06-11), no blockers remaining — TS toolchain added to the shared RCL with source co-located at `DeepDrftShared.Client/Interop/parallax/parallax.ts` → `wwwroot/js/parallax/parallax.js`, served from `_content/DeepDrftShared.Client/…` to both hosts; and parallax direction is exposed as the `InvertDirection` component parameter rather than hardcoded. See product note §6a/§11.1 and §3/§11.2.
|
||||
|
||||
**Completion note:** `ParallaxImage.razor` + `.razor.cs` + `.razor.css` implemented in `DeepDrftShared.Client/Components/`. TS interop module at `DeepDrftShared.Client/Interop/parallax/parallax.ts` compiled to `wwwroot/js/parallax/parallax.js`. `Microsoft.TypeScript.MSBuild` 5.9.3 added to `DeepDrftShared.Client.csproj`, matching the pattern in `DeepDrftPublic`. Component exposes `InvertDirection` parameter for parallax direction; scroll-offset math and IntersectionObserver lifecycle owned by the TS module via `IJSObjectReference` interop.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — CMS Enhancements
|
||||
|
||||
### 6.3 Batch Upload Page
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** Replace the single-track form at `/tracks/new` with a two-panel batch upload page that uploads many WAVs in one session under a shared album header.
|
||||
- **Why:** Uploading an album one track at a time is the current reality — re-entering album, genre, release date, cover art, and artist on every track. Batch upload makes "add a release" a single operation: set the shared header once, queue the tracks, submit. This is the dominant ingestion shape for the collective (releases, not loose singles).
|
||||
- **Shape:**
|
||||
- **Route:** New page at **`/tracks/upload`**. Justification: `/tracks/new` reads as "new single track" and the edit route is `/tracks/{id}`; `/tracks/upload` names the operation (batch ingestion) without colliding with the id-parameterised edit route. Repoint the "Add Track" button in `TrackList.razor` (currently `Href="/tracks/new"`) to `/tracks/upload`. Whether `/tracks/new` is retired or left as a redirect is staff-engineer's call; the committed change is that the button goes to the batch page.
|
||||
- **Data model change — `ReleaseType`:** Add a `ReleaseType` enum to `DeepDrftModels` (`enum ReleaseType { Single, EP, Album }`). Enum over string: three fixed values, and it gates UI (selector) and future grouping logic — a free-text column invites typos. Add a `ReleaseType` property to **`TrackEntity`** and **`TrackDto`**. Decide nullability: recommend **non-null with a default of `Single`** so existing rows backfill cleanly to a sensible value (a release of one track is a single) and the column is never null. This ripples to `TrackConfiguration` (EF mapping — store as string via `HasConversion<string>()` for readable DB values, or as int; recommend string for legibility), `TrackConverter` (assign on round-trip), and the upload/update service signatures. **An EF migration is required** — author it via `dotnet ef migrations add`, never by hand.
|
||||
- **Data model change — `TrackNumber`:** Add a `TrackNumber` property (type `int`, **1-based, non-null**) to **`TrackEntity`** and **`TrackDto`** to store per-track ordinal position within a release. This ripples through `TrackConfiguration` (EF mapping) and `TrackConverter` (assign on round-trip) the same way `ReleaseType` does. **A second EF migration is required** — author it via `dotnet ef migrations add`, never by hand. May be combined into a single migration with the `ReleaseType` change — staff-engineer's call on whether to combine or keep separate.
|
||||
- **Shared-vs-per-track field split:**
|
||||
- *Shared (header strip, applied to every track in the batch):* album name, artist, album cover image (single upload), genre, release date, and `ReleaseType`. One album per batch — the entire batch is one release, and all release-level fields live in the header.
|
||||
- *Per-track (right detail panel):* track name, the individual WAV file, and that row's upload status.
|
||||
- **Layout (two-panel under a header strip):**
|
||||
- **Header strip** (full width, top): album name, artist `MudTextField`, single cover-art `InputFile` (reuse the `MudField` cover-art pattern from `TrackNew`, including the upload-on-submit behaviour), genre `MudTextField`, release-date field, and `ReleaseType` `MudSelect`. These bind to a single batch-header model.
|
||||
- **Left panel** (track queue): an ordered list of queued tracks; the row order *is* the release track order and reflects each track's `TrackNumber`. Each row shows track name, a reorder affordance (up/down `MudIconButton`s are the low-risk choice; drag-and-drop is a nice-to-have — see open questions), a remove button, and a per-row status indicator (queued / uploading / done / failed). A `+`/`InputFile` (with `multiple`) at the top or bottom of the list adds WAV files; each added file becomes a row with track name defaulted from the filename (sans extension). On submit, each track is assigned its `TrackNumber` (1-based) from its position in the list.
|
||||
- **Right panel** (selected-track detail): when a row is selected, show its editable fields — track name and the WAV file name/size/status. Selecting a different row swaps the detail.
|
||||
- **Add-files behaviour:** `InputFile multiple` → append a row per file. Default track name = filename without extension. New rows append to the end of the list, taking the next ordinal position. Keep the 1 GB per-file ceiling and the `.wav` validation from `TrackNew`.
|
||||
- **Submit behaviour:** Sequential, one request at a time — reuse the existing single-track upload path (`CmsTrackService.UploadTrackAsync`) in a loop. This mirrors the deliberately-sequential waveform backfill in `TrackList.GenerateAllMissing` ("one request at a time so a large backfill does not flood the API"). Per-track progress: each left-panel row reflects its state as the loop advances (`StateHasChanged` between rows). Cover-art upload happens **once** before the loop (upload the image, get the entry key, then pass/link it to every track) — do not re-upload the cover per track. On completion, snackbar a summary (`uploaded N, M failed`) and navigate to `/tracks`. Partial failure: completed tracks stay persisted; failed rows remain visible with their error so the admin can retry just those — do **not** roll back the batch.
|
||||
- **CmsTrackService surface:** No new method strictly required — the loop calls the existing `UploadTrackAsync` per track and the existing image upload/link path per batch. `UploadTrackAsync`'s signature gains `releaseType` and `trackNumber` parameters (ripples from the data-model change). If the cover-link follow-up (the `UpdateAsync` step `TrackNew` does today) is kept per track, that's existing surface too.
|
||||
- **API surface:** No new endpoints. Existing `POST api/track/upload` (per track) and `POST api/image/upload` (once per batch) cover it. `api/track/upload` and the metadata update endpoints gain `releaseType` and `trackNumber` in their payloads as a consequence of the entity change.
|
||||
- **Components:** `BatchUpload.razor` (page + header strip + orchestration), and reasonably a `BatchTrackRow` model class plus left-panel/right-panel as child components or inline sections — staff-engineer's structural call.
|
||||
- **Constraint — dual-write orphan risk:** Each track inherits the existing dual-write hazard (audio lands in the vault, SQL persist may fail → orphaned audio, no rollback). Batch upload *multiplies the exposure* (N tracks per session instead of one). The mitigation is **Phase 4.3 (dual-write rollback / dead-letter log)** — not a blocker for this feature, but this is the strongest argument yet for landing 4.3. Flag it as a known constraint; do not attempt per-batch transactional rollback (the dual-database split can't give it).
|
||||
- **Prerequisites:**
|
||||
- `ReleaseType` enum + `TrackNumber` field + `TrackEntity`/`TrackDto` changes + EF migration(s) must land first (it's the data-model floor for the whole feature, and ripples through `TrackConfiguration`/`TrackConverter`/service signatures). Could be a separate prep commit before the page work.
|
||||
- **Not blocked by** Phase 4.3, but 4.3 is the right mitigation for the amplified orphan risk and is worth sequencing alongside.
|
||||
- **Resolved (no longer open):**
|
||||
- **One album per batch.** The whole batch is one release; album name and all release-level fields (artist, genre, release date, `ReleaseType`, cover art) live in the shared header strip. A batch never mixes albums.
|
||||
- **Track ordinals are persistent** — `TrackNumber` (int, 1-based, non-null) stores per-track position within a release. The left-panel row order reflects `TrackNumber`, and each track is assigned its ordinal from its list position on submit.
|
||||
|
||||
**Completion note:** `BatchUpload.razor` page implemented at `/tracks/upload`; two-panel layout with header strip (shared album/artist/genre/release-date/cover-art/release-type fields) and left queue + right detail sections for per-track track name and file selection. Sequential upload loop via existing `CmsTrackService.UploadTrackAsync`. Cover-art uploaded once at start; per-track progress reflected in left-panel status indicators. `TrackList.razor` "Add Track" button repointed to `/tracks/upload`. `ReleaseType` enum and `TrackNumber` int field added to `TrackEntity`, `TrackDto`, `TrackConfiguration`, `TrackConverter`, and EF migrations authored. `UploadTrackAsync` signature updated with `releaseType` and `trackNumber` parameters.
|
||||
|
||||
---
|
||||
|
||||
### 6.1 CMS Home Page — catalogue summary dashboard
|
||||
|
||||
**Landed:** 2026-06-11 on dev.
|
||||
|
||||
- **What:** Replace the redirect-to-`/tracks` at `Index.razor` (route `/`) with a real dashboard showing a grid of summary cards: total tracks, distinct albums, distinct genres.
|
||||
- **Why:** Quick orientation for the CMS admin — at-a-glance catalogue health on landing, instead of dropping straight into the table. First thing the admin sees, so it carries the bold DeepDrft palette rather than a conservative admin look.
|
||||
- **Shape:**
|
||||
- **Route / component:** Keep `Index.razor` at `/`; remove the `OnInitialized` redirect and render the dashboard. The CMS nav lands here; `/tracks` remains reachable from the nav and from the cards.
|
||||
- **UI:** A responsive `MudGrid` of three `MudCard`s (Tracks / Albums / Genres). Each card: an icon (`LibraryMusic`, `Album`, `Category` or similar), the metric as a large `Typo.h2`/`h3` number, and a label. Cards are clickable (`@onclick` → `Nav.NavigateTo`). Lean into the active MudBlazor palette — `Color.Primary`/`Color.Secondary` fills or accent borders, generous elevation — this is the visual-punch surface, not a muted KPI strip. Loading state: skeleton or per-card `MudProgressCircular` while the three fetches resolve. Each card fetches independently so one slow/failed call doesn't blank the others; a failed card shows a "—" with a retry affordance rather than collapsing the grid.
|
||||
- **Card navigation (Phase 6 scope):** All three cards navigate to `/tracks` (the track maintenance page). **Per-album / per-genre pre-filtering is deferred** — see 6.2. Ship the cards as plain links to `/tracks` now.
|
||||
- **Data model:** No entity changes. `AlbumSummaryDto` and `GenreSummaryDto` already exist in `DeepDrftModels`.
|
||||
- **API surface:** No new API endpoints. The three numbers are already available:
|
||||
- **Albums count** = length of `GET api/track/albums` (exists, unauthenticated, returns `List<AlbumSummaryDto>`).
|
||||
- **Genres count** = length of `GET api/track/genres` (exists, unauthenticated, returns `List<GenreSummaryDto>`).
|
||||
- **Tracks count** = `TotalCount` from `GET api/track/page` (exists) requested with `pageSize=1` (cheapest paged call that still returns the total).
|
||||
- **CmsTrackService surface (new methods):** `ICmsTrackService` does not currently expose albums/genres. Add three thin proxy methods mirroring the existing pattern (e.g. `GetAlbumSummariesAsync`, `GetGenreSummariesAsync`, and a `GetTrackCountAsync` that calls `page?pageSize=1` and returns `TotalCount`). These are the only new code on the service. No controller work.
|
||||
- **Components:** `Index.razor` (dashboard host) plus, optionally, a small `SummaryCard.razor` for the repeated card — worth extracting given three near-identical cards, but staff-engineer's call.
|
||||
- **Prerequisites:** None. All backing endpoints and DTOs exist.
|
||||
|
||||
**Completion note:** `DeepDrftManager/Components/Pages/Index.razor` redesigned as a 3-card dashboard grid (Tracks / Albums / Genres counts) with independent per-card fetches. Three new `ICmsTrackService` proxy methods (`GetAlbumSummariesAsync`, `GetGenreSummariesAsync`, `GetTrackCountAsync`) wired to existing public API endpoints. Cards navigate to `/tracks` on click. Failed cards show "—" fallback; each card loads independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1.1 — Extended WAV format support
|
||||
|
||||
**Status:** Fully landed on 2026-06-10 (IEEE Float SubFormat 0x0003 and Padded 24-in-32 container support implemented, tests passing).
|
||||
|
||||
- **What:** Two EXTENSIBLE WAV sub-cases that were explicitly scoped out of the `WAVE_FORMAT_EXTENSIBLE` PCM fix (which shipped support for `audioFormat=0xFFFE` with a PCM SubFormat — the Bandcamp WAV download case). Both are currently rejected at `AudioProcessor.ValidateAudioParameters` and fall back to default metadata. The inline comments at `AudioProcessor.cs` (SubFormat check ~L182–188, BlockAlign note ~L225–230) mark them as accepted gaps as of that fix.
|
||||
- **EXTENSIBLE non-PCM SubFormats** — e.g. IEEE Float (32-bit float PCM, common in DAW exports). The SubFormat-GUID check accepts only PCM (`0x0001`) today; anything else is rejected outright.
|
||||
- **Padded-container EXTENSIBLE** — 24-bit valid samples in a 32-bit container (`wValidBitsPerSample=24`, container `bitsPerSample=32`). The BlockAlign check fails because the valid-bit depth (24) doesn't match the container's block align.
|
||||
- **Why it matters:** DAW exports — the dominant shape of source material as the collective uploads more of its own production — tend to be float WAV or padded 24-bit. The shipped fix covers consumer/Bandcamp WAVs but not the producer's working files.
|
||||
- **Shape:** Both live in the same seam as the shipped fix (`AudioProcessor` validation + the `NormalizeToStandardPcm` storage step), but the work differs by case:
|
||||
- *Float SubFormat:* requires float→integer sample conversion during the normalize-to-standard-PCM step (the vault stays integer-PCM so the streaming/decode pipeline is unchanged), or a Web Audio decode path that handles float directly. The conversion-at-storage option keeps the load-bearing streaming seam untouched and is the lower-risk path.
|
||||
- *Padded 24-in-32:* relax `ValidateAudioParameters` to tolerate the BlockAlign mismatch when `IsExtensible`, then normalize to the valid-bit depth (24) during storage so the stored WAV is canonical.
|
||||
- **Prerequisite:** None. Both are self-contained extensions of the WAV path that just landed; neither depends on the broader format-router work in 1.2.
|
||||
- **Relationship to 1.2:** Distinct from it. 1.2 is new *containers* (MP3, FLAC, Ogg) behind a format router; this is additional *WAV variants* on the existing PCM path. If 1.2's router lands first, these become per-variant branches inside the WAV processor rather than new processors.
|
||||
|
||||
**Completion note:** IEEE Float SubFormat (0x0003) support added via `ConvertFloatTo24BitPcm` conversion at storage time; Padded 24-in-32 container support added via `RepackPaddedContainer` with relaxed `ValidateAudioParameters` BlockAlign check. Both cases tested in 8 new `AudioProcessorTests` cases. Vault stores standard 24-bit PCM in both cases; streaming/decode pipeline unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.2 + 2.3 — Album/genre views and gallery search/filter
|
||||
|
||||
**Status:** Fully landed on 2026-06-10.
|
||||
|
||||
@@ -50,21 +50,21 @@ Returns the WAV bytes from the `tracks` vault with HTTP Range support.
|
||||
|
||||
### POST api/track/upload ([ApiKeyAuthorize])
|
||||
|
||||
**Authenticated endpoint.** Accepts a raw WAV upload + metadata as `multipart/form-data`, processes the WAV, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackDto` with `Id` populated.
|
||||
**Authenticated endpoint.** Accepts a raw audio file upload (.wav, .mp3, .flac) + metadata as `multipart/form-data`, processes the file, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackDto` with `Id` populated.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Form fields**:
|
||||
- `wav` (`IFormFile`, required): the WAV bytes. File name must end in `.wav`.
|
||||
- `audioFile` (`IFormFile`, required): the audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
|
||||
- `trackName` (string, required)
|
||||
- `artist` (string, required)
|
||||
- `album` (string, optional)
|
||||
- `genre` (string, optional)
|
||||
- `releaseDate` (string, optional, format `YYYY-MM-DD`)
|
||||
- `createdByUserId` (long, required): audit trail — who uploaded this track.
|
||||
- The upload stream is copied to a `.wav`-suffixed temp file under `Path.GetTempPath()` (the audio processor requires that extension and reads from disk). The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized WAVs are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackService.AddTrackFromWavAsync` (vault write) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields. Returns 500 if processing fails.
|
||||
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 500 if processing fails.
|
||||
|
||||
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
@@ -76,12 +77,13 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
|
||||
// GET api/track/albums (unauthenticated)
|
||||
// Distinct non-null albums with track counts and cover keys. Public browse data, same posture as
|
||||
// GET api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
||||
// All releases with per-release track counts. Public browse data, same posture as GET
|
||||
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
||||
// Route name kept as "albums" for client/proxy compatibility; the payload is List<ReleaseDto>.
|
||||
[HttpGet("albums")]
|
||||
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetDistinctAlbums(ct);
|
||||
var result = await _sqlTrackService.GetReleases(ct);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
@@ -166,11 +168,12 @@ public class TrackController : ControllerBase
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||
// Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never
|
||||
// touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write.
|
||||
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
|
||||
// proxies the upload here so it never touches the vault disk path or SQL directly.
|
||||
// UnifiedTrackService owns the two-database write.
|
||||
//
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds
|
||||
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
|
||||
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
|
||||
// not a buffered allocation.
|
||||
@@ -179,7 +182,7 @@ public class TrackController : ControllerBase
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
|
||||
[FromForm] IFormFile? wav,
|
||||
[FromForm] IFormFile? audioFile,
|
||||
[FromForm] string? trackName,
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
@@ -187,14 +190,16 @@ public class TrackController : ControllerBase
|
||||
[FromForm] string? releaseDate,
|
||||
[FromForm] string? originalFileName,
|
||||
[FromForm] long createdByUserId,
|
||||
[FromForm] string? releaseType,
|
||||
[FromForm] int? trackNumber,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
|
||||
trackName, artist, originalFileName, wav?.Length);
|
||||
trackName, artist, originalFileName, audioFile?.Length);
|
||||
|
||||
if (wav is null || wav.Length == 0)
|
||||
if (audioFile is null || audioFile.Length == 0)
|
||||
{
|
||||
return BadRequest("WAV file is required");
|
||||
return BadRequest("Audio file is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trackName))
|
||||
@@ -207,9 +212,10 @@ public class TrackController : ControllerBase
|
||||
return BadRequest("artist is required");
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase))
|
||||
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
|
||||
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
|
||||
{
|
||||
return BadRequest("Uploaded file must have a .wav extension");
|
||||
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
|
||||
}
|
||||
|
||||
DateOnly? parsedReleaseDate = null;
|
||||
@@ -222,16 +228,33 @@ public class TrackController : ControllerBase
|
||||
parsedReleaseDate = parsed;
|
||||
}
|
||||
|
||||
// AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk.
|
||||
// Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav");
|
||||
// Default to Single for null/unparseable release type; default track number to a valid 1-based value.
|
||||
ReleaseType parsedReleaseType;
|
||||
if (!string.IsNullOrWhiteSpace(releaseType)
|
||||
&& Enum.TryParse<ReleaseType>(releaseType, ignoreCase: true, out var rt)
|
||||
&& Enum.IsDefined(rt))
|
||||
{
|
||||
parsedReleaseType = rt;
|
||||
}
|
||||
else
|
||||
{
|
||||
parsedReleaseType = ReleaseType.Single;
|
||||
if (!string.IsNullOrWhiteSpace(releaseType))
|
||||
_logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
|
||||
}
|
||||
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
|
||||
|
||||
// The processor router selects by extension and reads from disk, so the temp file must carry
|
||||
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
|
||||
// generate our own path preserving the validated .wav/.mp3/.flac extension.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
await using (var uploadStream = wav.OpenReadStream())
|
||||
await using (var uploadStream = audioFile.OpenReadStream())
|
||||
{
|
||||
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
||||
}
|
||||
@@ -245,11 +268,13 @@ public class TrackController : ControllerBase
|
||||
parsedReleaseDate,
|
||||
createdByUserId,
|
||||
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
||||
parsedReleaseType,
|
||||
resolvedTrackNumber,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV";
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
|
||||
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
@@ -339,16 +364,36 @@ public class TrackController : ControllerBase
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var track = lookup.Value;
|
||||
track.TrackName = request.TrackName;
|
||||
track.Artist = request.Artist;
|
||||
track.Album = request.Album;
|
||||
track.Genre = request.Genre;
|
||||
track.ReleaseDate = request.ReleaseDate;
|
||||
if (request.TrackNumber is <= 0)
|
||||
return BadRequest("trackNumber must be a positive integer when provided.");
|
||||
|
||||
// Only update ImagePath when the request explicitly provides a value (null = no change, "" = clear).
|
||||
var track = lookup.Value;
|
||||
|
||||
// Track-cardinal fields update the track row directly.
|
||||
track.TrackName = request.TrackName;
|
||||
if (request.TrackNumber is > 0)
|
||||
track.TrackNumber = request.TrackNumber.Value;
|
||||
|
||||
// Release-cardinal fields update the linked release (handled in TrackManager.Update, which
|
||||
// persists track.Release when the track carries a resolved ReleaseId). The loaded track has
|
||||
// its Release populated via the Include; mutate it in place so the edited values flow through.
|
||||
// A loose track (no release) cannot take release-cardinal edits — there is no release row to
|
||||
// write to — so these fields are simply not persisted in that case.
|
||||
if (track.Release is { } release)
|
||||
{
|
||||
release.Artist = request.Artist;
|
||||
release.Title = request.Album ?? string.Empty;
|
||||
release.Genre = request.Genre;
|
||||
release.ReleaseDate = request.ReleaseDate;
|
||||
|
||||
// ImagePath is tri-state: null = no change, "" = clear, value = set.
|
||||
if (request.ImagePath is not null)
|
||||
track.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
|
||||
release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
|
||||
|
||||
// ReleaseType is non-null on the release; null in the request means "no change".
|
||||
if (request.ReleaseType is not null)
|
||||
release.ReleaseType = request.ReleaseType.Value;
|
||||
}
|
||||
|
||||
var update = await _sqlTrackService.Update(track);
|
||||
if (!update.Success)
|
||||
@@ -386,6 +431,22 @@ public class TrackController : ControllerBase
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// DELETE api/track/release/{id} ([ApiKeyAuthorize])
|
||||
// Soft-delete a release row directly. Used by the albums browser to remove an orphaned release
|
||||
// (one with no live tracks). "release" is a literal segment, declared here in the literal-route
|
||||
// block so it never resolves to the parameterized "{trackId}" GET.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpDelete("release/{id:long}")]
|
||||
public async Task<ActionResult> DeleteRelease(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _sqlTrackService.DeleteRelease(id, cancellationToken);
|
||||
if (result.Success) return Ok();
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogError("DeleteRelease failed for id {Id}: {Error}", id, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// --- Parameterized routes ---
|
||||
|
||||
[HttpGet("{trackId}")]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftAPI.Models;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,4 +17,6 @@ public record UpdateTrackMetadataRequest(
|
||||
string? Album,
|
||||
string? Genre,
|
||||
DateOnly? ReleaseDate,
|
||||
string? ImagePath = null);
|
||||
string? ImagePath = null,
|
||||
ReleaseType? ReleaseType = null,
|
||||
int? TrackNumber = null);
|
||||
|
||||
@@ -3,6 +3,7 @@ using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
@@ -37,9 +38,10 @@ public class UnifiedTrackService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a WAV into the vault, then persist its metadata to SQL. On success the returned
|
||||
/// DTO carries the SQL-assigned Id. If the vault write succeeds but the SQL persist fails,
|
||||
/// the audio is orphaned under EntryKey — logged loudly so it is recoverable manually.
|
||||
/// Process a supported audio file (.wav, .mp3, .flac) into the vault, then persist its metadata
|
||||
/// to SQL. On success the returned DTO carries the SQL-assigned Id. If the vault write succeeds
|
||||
/// but the SQL persist fails, the audio is orphaned under EntryKey — logged loudly so it is
|
||||
/// recoverable manually.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<TrackDto>> UploadAsync(
|
||||
string tempFilePath,
|
||||
@@ -50,9 +52,11 @@ public class UnifiedTrackService
|
||||
DateOnly? releaseDate,
|
||||
long createdByUserId,
|
||||
string? originalFileName,
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
|
||||
var unpersisted = await _contentTrackContentService.AddTrackAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
|
||||
|
||||
if (unpersisted is null)
|
||||
@@ -61,9 +65,43 @@ public class UnifiedTrackService
|
||||
return ResultContainer<TrackDto>.CreateFailResult("Failed to process and store WAV.");
|
||||
}
|
||||
|
||||
unpersisted.CreatedByUserId = createdByUserId;
|
||||
unpersisted.TrackNumber = trackNumber;
|
||||
|
||||
var saveResult = await _sqlTrackService.Create(TrackConverter.Convert(unpersisted));
|
||||
// Resolve the release FK before persisting the track. An upload with an album lands on the
|
||||
// shared release (created on first sighting); an upload without one stays a loose track with
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
|
||||
// on the release, not the track.
|
||||
long? releaseId = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
var releaseData = new ReleaseDto
|
||||
{
|
||||
Title = album,
|
||||
Artist = artist,
|
||||
Genre = genre,
|
||||
ReleaseDate = releaseDate,
|
||||
ReleaseType = releaseType,
|
||||
CreatedByUserId = createdByUserId,
|
||||
};
|
||||
|
||||
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
|
||||
if (!releaseResult.Success || releaseResult.Value is null)
|
||||
{
|
||||
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Track persisted to vault but release resolution failed. Orphaned entry: {EntryKey}. Error: {Error}",
|
||||
unpersisted.EntryKey, error);
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
releaseId = releaseResult.Value.Id;
|
||||
}
|
||||
|
||||
var trackDto = TrackConverter.Convert(unpersisted);
|
||||
trackDto.ReleaseId = releaseId;
|
||||
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
|
||||
|
||||
var saveResult = await _sqlTrackService.Create(trackDto);
|
||||
if (!saveResult.Success || saveResult.Value is null)
|
||||
{
|
||||
// Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault
|
||||
@@ -117,6 +155,7 @@ public class UnifiedTrackService
|
||||
}
|
||||
|
||||
var entryKey = lookup.Value.EntryKey;
|
||||
var releaseId = lookup.Value.ReleaseId;
|
||||
|
||||
var sqlDelete = await _sqlTrackService.Delete(id);
|
||||
if (!sqlDelete.Success)
|
||||
@@ -126,6 +165,14 @@ public class UnifiedTrackService
|
||||
return Result.CreateFailResult("Failed to delete track.");
|
||||
}
|
||||
|
||||
// Cascade: if this was the last live track on its release, soft-delete the release too so it
|
||||
// does not linger as a 0-track orphan in the albums browser. Non-fatal — the track delete
|
||||
// already succeeded, so any failure here is logged and swallowed, not surfaced to the caller.
|
||||
if (releaseId is { } rid)
|
||||
{
|
||||
await TrySoftDeleteEmptyReleaseAsync(rid, ct);
|
||||
}
|
||||
|
||||
// Tri-state per FileDatabase's error-swallow contract: null = vault missing/error,
|
||||
// false = entry not present, true = removed. Anything but a clean removal is an orphan.
|
||||
var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
|
||||
@@ -138,4 +185,30 @@ public class UnifiedTrackService
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
// Soft-delete the release only when no live tracks remain on it. Best-effort: a count or delete
|
||||
// failure here never fails the track delete that triggered it — it is logged so an orphaned
|
||||
// release can be cleaned up later (the migration backfill also catches pre-existing orphans).
|
||||
private async Task TrySoftDeleteEmptyReleaseAsync(long releaseId, CancellationToken ct)
|
||||
{
|
||||
var countResult = await _sqlTrackService.CountLiveTracksByRelease(releaseId, ct);
|
||||
if (!countResult.Success)
|
||||
{
|
||||
var error = countResult.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogWarning("DeleteAsync: live-track count failed for release {ReleaseId}: {Error}", releaseId, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (countResult.Value > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var releaseDelete = await _sqlTrackService.DeleteRelease(releaseId, ct);
|
||||
if (!releaseDelete.Success)
|
||||
{
|
||||
var error = releaseDelete.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogWarning("DeleteAsync: release soft-delete failed for {ReleaseId}: {Error}", releaseId, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace DeepDrftAPI
|
||||
{
|
||||
// Audio services
|
||||
builder.Services.AddSingleton<AudioProcessor>();
|
||||
builder.Services.AddSingleton<Mp3AudioProcessor>();
|
||||
builder.Services.AddSingleton<FlacAudioProcessor>();
|
||||
builder.Services.AddSingleton<AudioProcessorRouter>();
|
||||
builder.Services.AddSingleton<TrackContentService>();
|
||||
|
||||
// Image services
|
||||
|
||||
@@ -74,19 +74,18 @@ public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, Fi
|
||||
|
||||
**Callers must check return values.** Do not change this without a deliberate design pass — it's embedded in all FileDatabase tests and client code.
|
||||
|
||||
## Audio processor
|
||||
## Audio processors
|
||||
|
||||
`AudioProcessor.ProcessWavFileAsync(filePath)`:
|
||||
Multi-format support via router pattern. All processors live in `DeepDrftContent/Processors/`:
|
||||
|
||||
1. Validates the RIFF/WAVE structure and format code.
|
||||
2. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM.
|
||||
3. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing in the vault.
|
||||
4. Parses the fmt and data chunks.
|
||||
5. Extracts duration (sample count / sample rate) and bitrate (file size / duration).
|
||||
6. Returns `AudioBinary` with all metadata.
|
||||
7. **Fallback**: If parsing fails, logs a warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo).
|
||||
- `AudioProcessor.ProcessWavFileAsync(filePath)`: WAV-specific processor. Validates RIFF/WAVE structure and format code. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing. Parses fmt and data chunks; extracts duration and bitrate. Returns `AudioBinary` with metadata. On parse failure, logs warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo). Accepts standard PCM (audioFormat=1), WAVE_FORMAT_EXTENSIBLE with PCM SubFormat (0x0001), IEEE Float SubFormat (0x0003), and Padded 24-in-32 containers; normalizes Float and padded inputs to standard 24-bit PCM before storage.
|
||||
- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. Returns `AudioBinary` with original bytes and `.mp3` extension. On parse failure, falls back to defaults (180s / 320 kbps).
|
||||
- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. Returns `AudioBinary` with original bytes and `.flac` extension. On parse failure, falls back to defaults (180s / 1411 kbps).
|
||||
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav` → `AudioProcessor`, `.mp3` → `Mp3AudioProcessor`, `.flac` → `FlacAudioProcessor`. Throws `ArgumentException` for unsupported extensions.
|
||||
|
||||
PCM-only (both standard and EXTENSIBLE variants). Other formats (mp3, flac, aac, ogg, m4a) are listed in `MimeTypeExtensions` but not implemented. EXTENSIBLE with non-PCM SubFormats are rejected. The processor validates RIFF/WAVE/PCM structure — anything else is rejected.
|
||||
Vault stores original bytes with correct extension and MIME type (inferred from file extension or content-type header at upload time).
|
||||
|
||||
The primary entry point is `TrackContentService.AddTrackAsync(filePath, mimeType)` — format-agnostic. It selects the right processor via `AudioProcessorRouter`, processes the file, generates an entry GUID, stores in vault, returns unpersisted `TrackEntity`. Legacy `AddTrackFromWavAsync(filePath)` is now a shim over `AddTrackAsync` for backward compatibility.
|
||||
|
||||
## Image processor
|
||||
|
||||
|
||||
@@ -69,11 +69,23 @@ public class AudioProcessor
|
||||
return null;
|
||||
}
|
||||
|
||||
// Float and padded-container EXTENSIBLE require a sample-level transform to become integer PCM.
|
||||
// TryExtractPcm feeds loudness analysis, not storage, and must not hand back float bytes
|
||||
// mislabeled as integer PCM — out of scope here, so treat them as "no profile computable".
|
||||
if (validation.IsFloat)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
WavMetadata metadata;
|
||||
try
|
||||
{
|
||||
metadata = ParseWavMetadata(bytes, validation);
|
||||
ValidateAudioParameters(metadata);
|
||||
if (metadata.IsPaddedContainer)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -162,10 +174,12 @@ public class AudioProcessor
|
||||
}
|
||||
|
||||
// Validate audio format. Standard PCM (1) is accepted directly. WAVE_FORMAT_EXTENSIBLE
|
||||
// (0xFFFE) is accepted only when its SubFormat GUID indicates PCM — the raw sample data is
|
||||
// then byte-identical to standard PCM and we normalize it downstream.
|
||||
// (0xFFFE) is accepted when its SubFormat GUID indicates PCM (0x0001) or IEEE float
|
||||
// (0x0003). PCM sample data is byte-identical to standard PCM; float data is converted to
|
||||
// 24-bit PCM downstream. Either way the vault only ever holds standard PCM.
|
||||
var audioFormat = BitConverter.ToUInt16(buffer, fmtChunkPos + 8);
|
||||
var isExtensible = false;
|
||||
var isFloat = false;
|
||||
if (audioFormat == 0xFFFE)
|
||||
{
|
||||
// EXTENSIBLE requires the full extension: 16 base + 2 cbSize + 22 extension = 40 bytes.
|
||||
@@ -180,15 +194,24 @@ public class AudioProcessor
|
||||
}
|
||||
|
||||
// SubFormat GUID begins 24 bytes into the fmt chunk data (fmtChunkPos + 8 + 24). Its
|
||||
// first two bytes are the little-endian format tag; 0x0001 == WAVE_FORMAT_PCM.
|
||||
// first two bytes are the little-endian format tag: 0x0001 == WAVE_FORMAT_PCM,
|
||||
// 0x0003 == WAVE_FORMAT_IEEE_FLOAT.
|
||||
var subFormatPos = fmtChunkPos + 8 + 24;
|
||||
if (buffer[subFormatPos] != 0x01 || buffer[subFormatPos + 1] != 0x00)
|
||||
var subFormatTag = BitConverter.ToUInt16(buffer, subFormatPos);
|
||||
if (subFormatTag == 0x0001)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE SubFormat is not PCM" };
|
||||
}
|
||||
|
||||
isExtensible = true;
|
||||
}
|
||||
else if (subFormatTag == 0x0003)
|
||||
{
|
||||
isExtensible = true;
|
||||
isFloat = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE SubFormat is neither PCM nor IEEE float" };
|
||||
}
|
||||
}
|
||||
else if (audioFormat != 1)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Only PCM format supported" };
|
||||
@@ -206,7 +229,8 @@ public class AudioProcessor
|
||||
IsValid = true,
|
||||
FmtChunkPos = fmtChunkPos,
|
||||
DataChunkPos = dataChunkPos,
|
||||
IsExtensible = isExtensible
|
||||
IsExtensible = isExtensible,
|
||||
IsFloat = isFloat
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,13 +248,19 @@ public class AudioProcessor
|
||||
|
||||
// For EXTENSIBLE the offset-22 field is the container width; the true sample depth lives in
|
||||
// wValidBitsPerSample (fmtChunkPos + 8 + 18). They usually match (Bandcamp 24-bit = 24/24)
|
||||
// but the valid bits are authoritative for the normalized header and metadata.
|
||||
// Note: padded-container EXTENSIBLE (e.g. 24-bit valid in a 32-bit container) is not yet
|
||||
// supported — the mismatched BlockAlign will cause ValidateAudioParameters to throw and fall
|
||||
// back to defaults. This is an accepted gap as of this fix.
|
||||
// but the valid bits are authoritative for the normalized header and metadata. When they
|
||||
// differ (e.g. 24-bit valid in a 32-bit container) we keep the container width separately so
|
||||
// ValidateAudioParameters can reconcile against the header BlockAlign and NormalizeToStandardPcm
|
||||
// can re-pack the padded frames.
|
||||
var containerBitsPerSample = 0;
|
||||
if (validation.IsExtensible)
|
||||
{
|
||||
bitsPerSample = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 8 + 18);
|
||||
var validBits = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 8 + 18);
|
||||
if (validBits != bitsPerSample)
|
||||
{
|
||||
containerBitsPerSample = bitsPerSample;
|
||||
}
|
||||
bitsPerSample = validBits;
|
||||
}
|
||||
|
||||
var duration = byteRate > 0 ? (double)dataSize / byteRate : 0.0;
|
||||
@@ -243,10 +273,12 @@ public class AudioProcessor
|
||||
SampleRate = (int)sampleRate,
|
||||
Channels = channels,
|
||||
BitsPerSample = bitsPerSample,
|
||||
ContainerBitsPerSample = containerBitsPerSample,
|
||||
BlockAlign = blockAlign,
|
||||
DataSize = (int)dataSize,
|
||||
DataChunkPos = validation.DataChunkPos,
|
||||
IsExtensible = validation.IsExtensible
|
||||
IsExtensible = validation.IsExtensible,
|
||||
IsFloat = validation.IsFloat
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,7 +305,11 @@ public class AudioProcessor
|
||||
throw new InvalidDataException($"Unsupported bit depth: {metadata.BitsPerSample}");
|
||||
}
|
||||
|
||||
var expectedBlockAlign = metadata.Channels * (metadata.BitsPerSample / 8);
|
||||
// The header BlockAlign reflects the container width, not the valid bit depth. For a padded
|
||||
// EXTENSIBLE container (e.g. 24-in-32) the container width is authoritative for this check;
|
||||
// NormalizeToStandardPcm re-packs the frames down to the valid depth afterwards.
|
||||
var blockAlignBits = metadata.IsPaddedContainer ? metadata.ContainerBitsPerSample : metadata.BitsPerSample;
|
||||
var expectedBlockAlign = metadata.Channels * (blockAlignBits / 8);
|
||||
if (metadata.BlockAlign != expectedBlockAlign)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid block align: expected {expectedBlockAlign}, got {metadata.BlockAlign}");
|
||||
@@ -281,21 +317,49 @@ public class AudioProcessor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds an EXTENSIBLE-PCM WAV as a canonical 44-byte-header standard PCM WAV (audioFormat = 1).
|
||||
/// The sample bytes are copied verbatim — EXTENSIBLE-PCM data is byte-identical to standard PCM —
|
||||
/// only the header is replaced, so the vault stores a format the streaming pipeline already handles.
|
||||
/// Rebuilds an EXTENSIBLE WAV as a canonical 44-byte-header standard PCM WAV (audioFormat = 1)
|
||||
/// so the vault only ever holds a format the streaming pipeline already handles. Three source
|
||||
/// shapes are normalized:
|
||||
/// <list type="bullet">
|
||||
/// <item>EXTENSIBLE-PCM (depth == container): sample bytes are byte-identical to standard PCM and
|
||||
/// copied verbatim; only the header is replaced.</item>
|
||||
/// <item>IEEE float: 32-bit float samples are converted to 24-bit signed integer PCM.</item>
|
||||
/// <item>Padded container (e.g. 24-in-32): the padding/sign-extension bytes are stripped, keeping
|
||||
/// the lowest valid bytes per sample.</item>
|
||||
/// </list>
|
||||
/// The output header always reports the valid bit depth (<see cref="WavMetadata.BitsPerSample"/>).
|
||||
/// </summary>
|
||||
private byte[] NormalizeToStandardPcm(byte[] buffer, WavMetadata metadata)
|
||||
{
|
||||
// Clamp the declared data size to what is actually present; some encoders overshoot.
|
||||
var dataStart = metadata.DataChunkPos + 8;
|
||||
var available = buffer.Length - dataStart;
|
||||
var dataSize = Math.Min(metadata.DataSize, available);
|
||||
var srcDataSize = Math.Min(metadata.DataSize, available);
|
||||
|
||||
byte[] dataBytes;
|
||||
int outBitsPerSample;
|
||||
if (metadata.IsFloat)
|
||||
{
|
||||
dataBytes = ConvertFloatTo24BitPcm(buffer, dataStart, srcDataSize);
|
||||
outBitsPerSample = 24;
|
||||
}
|
||||
else if (metadata.IsPaddedContainer)
|
||||
{
|
||||
dataBytes = RepackPaddedContainer(buffer, dataStart, srcDataSize, metadata.ContainerBitsPerSample, metadata.BitsPerSample);
|
||||
outBitsPerSample = metadata.BitsPerSample;
|
||||
}
|
||||
else
|
||||
{
|
||||
dataBytes = new byte[srcDataSize];
|
||||
Array.Copy(buffer, dataStart, dataBytes, 0, srcDataSize);
|
||||
outBitsPerSample = metadata.BitsPerSample;
|
||||
}
|
||||
|
||||
var dataSize = dataBytes.Length;
|
||||
const int headerSize = 44;
|
||||
var result = new byte[headerSize + dataSize];
|
||||
|
||||
var blockAlign = (ushort)(metadata.Channels * (metadata.BitsPerSample / 8));
|
||||
var blockAlign = (ushort)(metadata.Channels * (outBitsPerSample / 8));
|
||||
var byteRate = (uint)(metadata.SampleRate * blockAlign);
|
||||
|
||||
// RIFF header
|
||||
@@ -311,17 +375,70 @@ public class AudioProcessor
|
||||
BitConverter.GetBytes((uint)metadata.SampleRate).CopyTo(result, 24);
|
||||
BitConverter.GetBytes(byteRate).CopyTo(result, 28);
|
||||
BitConverter.GetBytes(blockAlign).CopyTo(result, 32);
|
||||
BitConverter.GetBytes((ushort)metadata.BitsPerSample).CopyTo(result, 34);
|
||||
BitConverter.GetBytes((ushort)outBitsPerSample).CopyTo(result, 34);
|
||||
|
||||
// data chunk
|
||||
System.Text.Encoding.ASCII.GetBytes("data").CopyTo(result, 36);
|
||||
BitConverter.GetBytes((uint)dataSize).CopyTo(result, 40);
|
||||
|
||||
Array.Copy(buffer, dataStart, result, headerSize, dataSize);
|
||||
Array.Copy(dataBytes, 0, result, headerSize, dataSize);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts 32-bit little-endian IEEE float samples (range [-1.0, 1.0]) to 24-bit signed PCM.
|
||||
/// Each 4-byte source sample becomes 3 little-endian output bytes; output size is 3/4 of input.
|
||||
/// Trailing bytes that do not form a complete 4-byte sample are ignored.
|
||||
/// </summary>
|
||||
private static byte[] ConvertFloatTo24BitPcm(byte[] buffer, int dataStart, int dataSize)
|
||||
{
|
||||
var sampleCount = dataSize / 4;
|
||||
var output = new byte[sampleCount * 3];
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
var sample = BitConverter.ToSingle(buffer, dataStart + i * 4);
|
||||
var value = (int)(sample * 8388607.0);
|
||||
value = Math.Clamp(value, -8388608, 8388607);
|
||||
|
||||
var o = i * 3;
|
||||
output[o] = (byte)(value & 0xFF);
|
||||
output[o + 1] = (byte)((value >> 8) & 0xFF);
|
||||
output[o + 2] = (byte)((value >> 16) & 0xFF);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips container padding from a padded-container EXTENSIBLE WAV (e.g. 24-bit valid samples
|
||||
/// stored in 32-bit containers), keeping only the lowest <paramref name="validBits"/> bytes of
|
||||
/// each little-endian sample. Output size is (validBits/containerBits) of input.
|
||||
/// Trailing bytes that do not form a complete container sample are ignored.
|
||||
/// </summary>
|
||||
private static byte[] RepackPaddedContainer(byte[] buffer, int dataStart, int dataSize, int containerBits, int validBits)
|
||||
{
|
||||
var containerBytes = containerBits / 8;
|
||||
var validBytes = validBits / 8;
|
||||
var sampleCount = dataSize / containerBytes;
|
||||
var output = new byte[sampleCount * validBytes];
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
var src = dataStart + i * containerBytes;
|
||||
var dst = i * validBytes;
|
||||
// Little-endian: the valid sample occupies the low bytes; the upper bytes are padding /
|
||||
// sign extension and are discarded.
|
||||
for (int b = 0; b < validBytes; b++)
|
||||
{
|
||||
output[dst + b] = buffer[src + b];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default WAV metadata for fallback scenarios
|
||||
/// </summary>
|
||||
@@ -389,11 +506,26 @@ public class AudioProcessor
|
||||
public int Bitrate { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
public int Channels { get; set; }
|
||||
|
||||
/// <summary>The valid sample depth — for EXTENSIBLE, wValidBitsPerSample.</summary>
|
||||
public int BitsPerSample { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The container sample width for a padded EXTENSIBLE WAV whose valid depth is narrower
|
||||
/// (e.g. 32 for a 24-in-32 file). Zero when the container matches the valid depth.
|
||||
/// </summary>
|
||||
public int ContainerBitsPerSample { get; set; }
|
||||
|
||||
public int BlockAlign { get; set; }
|
||||
public int DataSize { get; set; }
|
||||
public int DataChunkPos { get; set; }
|
||||
public bool IsExtensible { get; set; }
|
||||
|
||||
/// <summary>True when the SubFormat is IEEE float (converted to 24-bit PCM on normalization).</summary>
|
||||
public bool IsFloat { get; set; }
|
||||
|
||||
/// <summary>True when valid samples are stored in a wider container that must be re-packed.</summary>
|
||||
public bool IsPaddedContainer => ContainerBitsPerSample != 0 && ContainerBitsPerSample != BitsPerSample;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -406,6 +538,9 @@ public class AudioProcessor
|
||||
public int FmtChunkPos { get; set; }
|
||||
public int DataChunkPos { get; set; }
|
||||
public bool IsExtensible { get; set; }
|
||||
|
||||
/// <summary>True when the EXTENSIBLE SubFormat is IEEE float rather than PCM.</summary>
|
||||
public bool IsFloat { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches an audio file to the correct format processor by extension. The single seam through
|
||||
/// which <see cref="TrackContentService"/> processes uploads, so callers depend on one abstraction
|
||||
/// rather than three concrete processors.
|
||||
/// </summary>
|
||||
public class AudioProcessorRouter
|
||||
{
|
||||
private readonly AudioProcessor _wavProcessor;
|
||||
private readonly Mp3AudioProcessor _mp3Processor;
|
||||
private readonly FlacAudioProcessor _flacProcessor;
|
||||
|
||||
public AudioProcessorRouter(
|
||||
AudioProcessor wavProcessor,
|
||||
Mp3AudioProcessor mp3Processor,
|
||||
FlacAudioProcessor flacProcessor)
|
||||
{
|
||||
_wavProcessor = wavProcessor;
|
||||
_mp3Processor = mp3Processor;
|
||||
_flacProcessor = flacProcessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes <paramref name="filePath"/> with the processor matching its extension, returning an
|
||||
/// <see cref="AudioBinary"/> carrying the stored bytes and extracted metadata. Throws
|
||||
/// <see cref="ArgumentException"/> for unsupported extensions.
|
||||
/// </summary>
|
||||
public async Task<AudioBinary?> ProcessAudioFileAsync(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".wav" => await _wavProcessor.ProcessWavFileAsync(filePath),
|
||||
".mp3" => await _mp3Processor.ProcessMp3FileAsync(filePath),
|
||||
".flac" => await _flacProcessor.ProcessFlacFileAsync(filePath),
|
||||
_ => throw new ArgumentException($"Unsupported audio format: {ext}", nameof(filePath)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from a FLAC file and wraps its <b>unmodified</b> bytes in an
|
||||
/// <see cref="AudioBinary"/> tagged <c>.flac</c>. No transcoding — the vault stores the original
|
||||
/// stream; duration and average bitrate come from the mandatory STREAMINFO metadata block.
|
||||
/// </summary>
|
||||
public class FlacAudioProcessor
|
||||
{
|
||||
private const double FallbackDuration = 180.0;
|
||||
private const int FallbackBitrate = 1411;
|
||||
|
||||
public async Task<AudioBinary?> ProcessFlacFileAsync(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"FLAC file not found: {filePath}");
|
||||
}
|
||||
|
||||
if (!Path.GetExtension(filePath).Equals(".flac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("File must be a FLAC file", nameof(filePath));
|
||||
}
|
||||
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var meta = ExtractFlacMetadata(buffer);
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Extension: ".flac",
|
||||
Duration: meta.Duration,
|
||||
Bitrate: meta.Bitrate);
|
||||
|
||||
return new AudioBinary(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the <c>fLaC</c> magic and the leading STREAMINFO block, then computes duration from
|
||||
/// total-samples / sample-rate and average bitrate from file size. On any parse failure, logs a
|
||||
/// warning and returns synthetic defaults — never throws.
|
||||
/// </summary>
|
||||
private static FlacMetadata ExtractFlacMetadata(byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Magic (4) + metadata block header (4) + STREAMINFO data (34) = 42 bytes minimum.
|
||||
if (buffer.Length < 42)
|
||||
{
|
||||
throw new InvalidDataException("File too short for FLAC STREAMINFO");
|
||||
}
|
||||
|
||||
if (buffer[0] != 'f' || buffer[1] != 'L' || buffer[2] != 'a' || buffer[3] != 'C')
|
||||
{
|
||||
throw new InvalidDataException("Invalid fLaC magic");
|
||||
}
|
||||
|
||||
// Metadata block header at offset 4: bits 6-0 of byte 0 are the block type (0 = STREAMINFO).
|
||||
var blockType = buffer[4] & 0x7F;
|
||||
if (blockType != 0)
|
||||
{
|
||||
throw new InvalidDataException($"First metadata block is not STREAMINFO (type {blockType})");
|
||||
}
|
||||
|
||||
// STREAMINFO data begins at offset 8. Layout (bit-packed, big-endian):
|
||||
// bytes 10-12 + top nibble of 13: sample rate (20 bits)
|
||||
// bits 3-1 of byte 12: channels - 1
|
||||
// bit 0 of byte 12 + top 4 bits of byte 13: bits per sample - 1
|
||||
// low nibble of byte 13 + bytes 14-17: total samples (36 bits)
|
||||
var d = 8;
|
||||
var sampleRate = (buffer[d + 10] << 12) | (buffer[d + 11] << 4) | (buffer[d + 12] >> 4);
|
||||
var totalSamples = ((long)(buffer[d + 13] & 0x0F) << 32)
|
||||
| ((long)buffer[d + 14] << 24)
|
||||
| ((long)buffer[d + 15] << 16)
|
||||
| ((long)buffer[d + 16] << 8)
|
||||
| buffer[d + 17];
|
||||
|
||||
if (sampleRate <= 0)
|
||||
{
|
||||
throw new InvalidDataException("Invalid FLAC sample rate");
|
||||
}
|
||||
|
||||
var duration = (double)totalSamples / sampleRate;
|
||||
var bitrate = duration > 0
|
||||
? (int)(buffer.LongLength * 8L / (duration * 1000))
|
||||
: FallbackBitrate;
|
||||
|
||||
return new FlacMetadata { Duration = duration, Bitrate = bitrate };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: FLAC parsing failed, using defaults: {ex.Message}");
|
||||
return new FlacMetadata { Duration = FallbackDuration, Bitrate = FallbackBitrate };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FlacMetadata
|
||||
{
|
||||
public double Duration { get; init; }
|
||||
public int Bitrate { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from an MP3 file and wraps its <b>unmodified</b> bytes in an
|
||||
/// <see cref="AudioBinary"/> tagged <c>.mp3</c>. No transcoding — the vault stores the original
|
||||
/// stream; only duration/bitrate metadata are computed from the first MPEG frame header (plus a
|
||||
/// Xing/VBRI tag when present for accurate VBR duration).
|
||||
/// </summary>
|
||||
public class Mp3AudioProcessor
|
||||
{
|
||||
// MPEG1 Layer III bitrate table (kbps), indexed by the 4-bit bitrate index. 0 = free, 15 = bad.
|
||||
private static readonly int[] Mpeg1Layer3Bitrates =
|
||||
[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
|
||||
|
||||
// MPEG2/2.5 Layer III bitrate table (kbps), indexed by 4-bit bitrate index. 0 = free, 15 = bad.
|
||||
private static readonly int[] Mpeg2Layer3Bitrates =
|
||||
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
|
||||
|
||||
private static readonly int[] Mpeg1SampleRates = [44100, 48000, 32000];
|
||||
private static readonly int[] Mpeg2SampleRates = [22050, 24000, 16000];
|
||||
private static readonly int[] Mpeg25SampleRates = [11025, 12000, 8000];
|
||||
|
||||
private const double FallbackDuration = 180.0;
|
||||
private const int FallbackBitrate = 320;
|
||||
|
||||
public async Task<AudioBinary?> ProcessMp3FileAsync(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"MP3 file not found: {filePath}");
|
||||
}
|
||||
|
||||
if (!Path.GetExtension(filePath).Equals(".mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("File must be an MP3 file", nameof(filePath));
|
||||
}
|
||||
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var meta = ExtractMp3Metadata(buffer);
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Extension: ".mp3",
|
||||
Duration: meta.Duration,
|
||||
Bitrate: meta.Bitrate);
|
||||
|
||||
return new AudioBinary(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the first valid MPEG frame (after any ID3v2 tag) and any Xing/VBRI tag inside it.
|
||||
/// On any parse failure, logs a warning and returns synthetic defaults — never throws.
|
||||
/// </summary>
|
||||
private static Mp3Metadata ExtractMp3Metadata(byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frameStart = FindFirstFrame(buffer);
|
||||
if (frameStart < 0)
|
||||
{
|
||||
throw new InvalidDataException("No valid MPEG frame sync found");
|
||||
}
|
||||
|
||||
var header = DecodeFrameHeader(buffer, frameStart);
|
||||
var duration = ComputeDuration(buffer, frameStart, header);
|
||||
|
||||
return new Mp3Metadata { Duration = duration, Bitrate = header.BitrateKbps };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: MP3 parsing failed, using defaults: {ex.Message}");
|
||||
return new Mp3Metadata { Duration = FallbackDuration, Bitrate = FallbackBitrate };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the offset of the first valid MPEG frame, skipping a leading ID3v2 tag if present.
|
||||
/// Scans for a 0xFF / 0xE0-syncword pair and fully validates the 4-byte header before accepting.
|
||||
/// </summary>
|
||||
private static int FindFirstFrame(byte[] buffer)
|
||||
{
|
||||
var start = SkipId3v2(buffer);
|
||||
|
||||
for (int i = start; i < buffer.Length - 4; i++)
|
||||
{
|
||||
if (buffer[i] != 0xFF || (buffer[i + 1] & 0xE0) != 0xE0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsValidFrameHeader(buffer, i))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the byte offset just past an ID3v2 tag, or 0 if none. The tag size is a syncsafe
|
||||
/// big-endian uint28 at bytes 6–9 (each byte's MSB is 0). A footer (flag bit 4 of byte 5) adds 10.
|
||||
/// </summary>
|
||||
private static int SkipId3v2(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 10 || buffer[0] != 'I' || buffer[1] != 'D' || buffer[2] != '3')
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var size = (buffer[6] << 21) | (buffer[7] << 14) | (buffer[8] << 7) | buffer[9];
|
||||
var skip = 10 + size;
|
||||
if ((buffer[5] & 0x10) != 0)
|
||||
{
|
||||
skip += 10; // footer present
|
||||
}
|
||||
|
||||
return skip <= buffer.Length ? skip : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fully validates a candidate 4-byte frame header: layer must be III, and version, bitrate
|
||||
/// index, and sample-rate index must all be non-reserved (rejects free bitrate, bad index 0xF,
|
||||
/// and reserved sample rate 3).
|
||||
/// </summary>
|
||||
private static bool IsValidFrameHeader(byte[] buffer, int pos)
|
||||
{
|
||||
var b1 = buffer[pos + 1];
|
||||
var b2 = buffer[pos + 2];
|
||||
|
||||
var versionBits = (b1 >> 3) & 0x03;
|
||||
if (versionBits == 1) // 1 = reserved
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var layerBits = (b1 >> 1) & 0x03;
|
||||
if (layerBits != 1) // 1 = Layer III; this processor handles Layer III only
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bitrateIndex = (b2 >> 4) & 0x0F;
|
||||
if (bitrateIndex == 0 || bitrateIndex == 0x0F) // 0 = free, 0xF = bad
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sampleRateIndex = (b2 >> 2) & 0x03;
|
||||
if (sampleRateIndex == 3) // reserved
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static FrameHeader DecodeFrameHeader(byte[] buffer, int pos)
|
||||
{
|
||||
var b1 = buffer[pos + 1];
|
||||
var b2 = buffer[pos + 2];
|
||||
var b3 = buffer[pos + 3];
|
||||
|
||||
var versionBits = (b1 >> 3) & 0x03;
|
||||
var version = versionBits switch
|
||||
{
|
||||
3 => MpegVersion.Mpeg1,
|
||||
2 => MpegVersion.Mpeg2,
|
||||
_ => MpegVersion.Mpeg25, // 0 = MPEG2.5
|
||||
};
|
||||
|
||||
var bitrateIndex = (b2 >> 4) & 0x0F;
|
||||
var bitrateTable = version == MpegVersion.Mpeg1 ? Mpeg1Layer3Bitrates : Mpeg2Layer3Bitrates;
|
||||
var bitrateKbps = bitrateTable[bitrateIndex];
|
||||
|
||||
var sampleRateIndex = (b2 >> 2) & 0x03;
|
||||
var sampleRate = version switch
|
||||
{
|
||||
MpegVersion.Mpeg1 => Mpeg1SampleRates[sampleRateIndex],
|
||||
MpegVersion.Mpeg2 => Mpeg2SampleRates[sampleRateIndex],
|
||||
_ => Mpeg25SampleRates[sampleRateIndex],
|
||||
};
|
||||
|
||||
var channelMode = (b3 >> 6) & 0x03;
|
||||
var channels = channelMode == 3 ? 1 : 2;
|
||||
var samplesPerFrame = version == MpegVersion.Mpeg1 ? 1152 : 576;
|
||||
|
||||
return new FrameHeader
|
||||
{
|
||||
Version = version,
|
||||
BitrateKbps = bitrateKbps,
|
||||
SampleRate = sampleRate,
|
||||
Channels = channels,
|
||||
SamplesPerFrame = samplesPerFrame,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes duration from a Xing/Info or VBRI tag (accurate for VBR) when present; otherwise
|
||||
/// falls back to the CBR estimate fileSize / (bitrate_kbps * 125). Guards divide-by-zero.
|
||||
/// </summary>
|
||||
private static double ComputeDuration(byte[] buffer, int frameStart, FrameHeader header)
|
||||
{
|
||||
var xingFrames = ReadXingFrameCount(buffer, frameStart, header);
|
||||
if (xingFrames > 0 && header.SampleRate > 0)
|
||||
{
|
||||
return (double)xingFrames * header.SamplesPerFrame / header.SampleRate;
|
||||
}
|
||||
|
||||
var vbriFrames = ReadVbriFrameCount(buffer, frameStart);
|
||||
if (vbriFrames > 0 && header.SampleRate > 0)
|
||||
{
|
||||
return (double)vbriFrames * header.SamplesPerFrame / header.SampleRate;
|
||||
}
|
||||
|
||||
// CBR fallback: bitrate_kbps * 1000 / 8 bytes per second = bitrate_kbps * 125.
|
||||
// Exclude the ID3v2 tag bytes (everything before frameStart) from the estimate.
|
||||
var bytesPerSecond = header.BitrateKbps * 125;
|
||||
return bytesPerSecond > 0 ? (double)(buffer.Length - frameStart) / bytesPerSecond : FallbackDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Xing/Info VBR total-frame count from the side-information region of the first frame,
|
||||
/// or 0 if no Xing tag or no frame-count flag. Side-info offset depends on version and channels.
|
||||
/// </summary>
|
||||
private static int ReadXingFrameCount(byte[] buffer, int frameStart, FrameHeader header)
|
||||
{
|
||||
var sideInfoSize = header.Version == MpegVersion.Mpeg1
|
||||
? (header.Channels == 1 ? 17 : 32)
|
||||
: (header.Channels == 1 ? 9 : 17);
|
||||
|
||||
var tagPos = frameStart + 4 + sideInfoSize;
|
||||
if (tagPos + 12 > buffer.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!MatchesAscii(buffer, tagPos, "Xing") && !MatchesAscii(buffer, tagPos, "Info"))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var flags = ReadUInt32BigEndian(buffer, tagPos + 4);
|
||||
if ((flags & 0x01) == 0) // bit 0 = frame-count present
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)ReadUInt32BigEndian(buffer, tagPos + 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Fraunhofer VBRI total-frame count. The VBRI tag sits at a fixed offset 32 past the
|
||||
/// frame header (frameStart + 4 + 32); the frame count is a big-endian uint32 at tag offset 14.
|
||||
/// </summary>
|
||||
private static int ReadVbriFrameCount(byte[] buffer, int frameStart)
|
||||
{
|
||||
var tagPos = frameStart + 4 + 32;
|
||||
if (tagPos + 18 > buffer.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!MatchesAscii(buffer, tagPos, "VBRI"))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)ReadUInt32BigEndian(buffer, tagPos + 14);
|
||||
}
|
||||
|
||||
private static bool MatchesAscii(byte[] buffer, int pos, string tag)
|
||||
{
|
||||
for (int i = 0; i < tag.Length; i++)
|
||||
{
|
||||
if (buffer[pos + i] != (byte)tag[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static uint ReadUInt32BigEndian(byte[] buffer, int pos) =>
|
||||
((uint)buffer[pos] << 24) | ((uint)buffer[pos + 1] << 16) | ((uint)buffer[pos + 2] << 8) | buffer[pos + 3];
|
||||
|
||||
private enum MpegVersion
|
||||
{
|
||||
Mpeg1,
|
||||
Mpeg2,
|
||||
Mpeg25,
|
||||
}
|
||||
|
||||
private sealed class FrameHeader
|
||||
{
|
||||
public MpegVersion Version { get; init; }
|
||||
public int BitrateKbps { get; init; }
|
||||
public int SampleRate { get; init; }
|
||||
public int Channels { get; init; }
|
||||
public int SamplesPerFrame { get; init; }
|
||||
}
|
||||
|
||||
private sealed class Mp3Metadata
|
||||
{
|
||||
public double Duration { get; init; }
|
||||
public int Bitrate { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,20 @@ namespace DeepDrftContent;
|
||||
public class TrackContentService
|
||||
{
|
||||
private readonly FileDatabase.Services.FileDatabase _fileDatabase;
|
||||
private readonly AudioProcessor _audioProcessor;
|
||||
private readonly AudioProcessorRouter _audioProcessorRouter;
|
||||
|
||||
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessor audioProcessor)
|
||||
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessorRouter audioProcessorRouter)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_audioProcessor = audioProcessor;
|
||||
_audioProcessorRouter = audioProcessorRouter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new track from a WAV file to both databases
|
||||
/// Adds a new track from a supported audio file (.wav, .mp3, .flac) to both databases. The
|
||||
/// router selects the processor by extension; original bytes are stored for mp3/flac (no
|
||||
/// transcoding), while EXTENSIBLE WAVs are normalized to standard PCM at storage time.
|
||||
/// </summary>
|
||||
/// <param name="wavFilePath">Path to the WAV file</param>
|
||||
/// <param name="audioFilePath">Path to the audio file</param>
|
||||
/// <param name="trackName">Name of the track</param>
|
||||
/// <param name="artist">Artist name</param>
|
||||
/// <param name="album">Optional album name</param>
|
||||
@@ -31,8 +33,8 @@ public class TrackContentService
|
||||
/// <param name="releaseDate">Optional release date</param>
|
||||
/// <param name="originalFileName">Optional original browser filename captured at upload time</param>
|
||||
/// <returns>The track entity with generated ID and media path</returns>
|
||||
public async Task<TrackEntity?> AddTrackFromWavAsync(
|
||||
string wavFilePath,
|
||||
public async Task<TrackEntity?> AddTrackAsync(
|
||||
string audioFilePath,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album = null,
|
||||
@@ -42,11 +44,11 @@ public class TrackContentService
|
||||
{
|
||||
try
|
||||
{
|
||||
// Process the WAV file
|
||||
var audioBinary = await _audioProcessor.ProcessWavFileAsync(wavFilePath);
|
||||
// Process the audio file (routed by extension)
|
||||
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
|
||||
if (audioBinary == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to process WAV file");
|
||||
throw new InvalidOperationException("Failed to process audio file");
|
||||
}
|
||||
|
||||
// Generate a unique track ID
|
||||
@@ -65,15 +67,13 @@ public class TrackContentService
|
||||
throw new InvalidOperationException("Failed to store audio in FileDatabase");
|
||||
}
|
||||
|
||||
// Create the track entity for SQL database
|
||||
// Create the track entity for SQL database. Post Phase 8 §8.0 the entity holds only
|
||||
// track-cardinal fields; release-cardinal data (artist/album/genre/releaseDate) is
|
||||
// resolved into a ReleaseEntity by the caller (UnifiedTrackService) and linked via FK.
|
||||
var trackEntity = new TrackEntity
|
||||
{
|
||||
EntryKey = trackId, // FileDatabase entry ID
|
||||
TrackName = trackName,
|
||||
Artist = artist,
|
||||
Album = album,
|
||||
Genre = genre,
|
||||
ReleaseDate = releaseDate,
|
||||
OriginalFileName = originalFileName
|
||||
};
|
||||
|
||||
@@ -81,11 +81,25 @@ public class TrackContentService
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.AddTrackFromWavAsync failed: {ex.Message}");
|
||||
Console.WriteLine($"TrackContentService.AddTrackAsync failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backward-compatible shim — delegates to <see cref="AddTrackAsync"/>. The router accepts WAV
|
||||
/// alongside MP3 and FLAC, so this carries no WAV-specific logic of its own.
|
||||
/// </summary>
|
||||
public Task<TrackEntity?> AddTrackFromWavAsync(
|
||||
string wavFilePath,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album = null,
|
||||
string? genre = null,
|
||||
DateOnly? releaseDate = null,
|
||||
string? originalFileName = null) =>
|
||||
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves audio binary from FileDatabase
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using Data.Data.Configurations;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<ReleaseEntity> builder)
|
||||
{
|
||||
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
|
||||
base.Configure(builder);
|
||||
|
||||
builder.ToTable("release");
|
||||
|
||||
// Map the base audit columns to the snake_case naming the rest of the schema uses.
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
|
||||
|
||||
builder.Property(e => e.Title)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("title");
|
||||
|
||||
builder.Property(e => e.Artist)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("artist");
|
||||
|
||||
builder.Property(e => e.Genre)
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
builder.Property(e => e.ImagePath)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnName("image_path");
|
||||
|
||||
builder.Property(e => e.ReleaseType)
|
||||
.IsRequired()
|
||||
.HasConversion<string>() // Store as readable string, not int ordinal
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("release_type")
|
||||
.HasDefaultValue(ReleaseType.Single);
|
||||
|
||||
builder.Property(e => e.CreatedByUserId)
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||
// "IX_release_is_deleted" regardless of auto-naming conventions.
|
||||
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
// Unique constraint on the natural key (title + artist). Prevents duplicate release rows
|
||||
// from concurrent uploads of the same album. The FindOrCreateRelease path catches the
|
||||
// resulting ClassifiedDbException (UniqueViolation) and re-queries for the winning row.
|
||||
builder.HasIndex(e => new { e.Title, e.Artist })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
}
|
||||
}
|
||||
@@ -30,33 +30,25 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("track_name");
|
||||
|
||||
builder.Property(e => e.Artist)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("artist");
|
||||
|
||||
builder.Property(e => e.Album)
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("album");
|
||||
|
||||
builder.Property(e => e.Genre)
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
builder.Property(e => e.ImagePath)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnName("image_path");
|
||||
|
||||
builder.Property(e => e.CreatedByUserId)
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
builder.Property(e => e.OriginalFileName)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
builder.Property(e => e.TrackNumber)
|
||||
.IsRequired()
|
||||
.HasColumnName("track_number")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
// Nullable FK to the release-cardinal row. SetNull on delete: removing a release leaves its
|
||||
// tracks intact as loose tracks rather than cascading them away.
|
||||
builder.HasOne(e => e.Release)
|
||||
.WithMany(r => r.Tracks)
|
||||
.HasForeignKey(e => e.ReleaseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||
// "IX_track_is_deleted" regardless of auto-naming conventions.
|
||||
|
||||
@@ -11,11 +11,13 @@ public class DeepDrftContext : DbContext
|
||||
}
|
||||
|
||||
public DbSet<TrackEntity> Tracks { get; set; }
|
||||
public DbSet<ReleaseEntity> Releases { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.ApplyConfiguration(new TrackConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,30 @@ public interface ITrackService
|
||||
Task<ResultContainer<List<TrackDto>>> GetAll();
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Distinct non-null albums with track counts and a representative cover key, album-ascending.</summary>
|
||||
Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default);
|
||||
/// <summary>All releases, title-ascending, each carrying its non-deleted track count.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
|
||||
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
|
||||
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
|
||||
/// </summary>
|
||||
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
||||
Task<ResultContainer<TrackDto>> Update(TrackDto track);
|
||||
Task<Result> Delete(long id);
|
||||
|
||||
/// <summary>Soft-delete a release row by id. Idempotent — a missing or already-deleted row is a no-op.</summary>
|
||||
Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count of non-deleted tracks on a release. Backs the delete-cascade decision: when a track
|
||||
/// delete leaves a release with zero live tracks, the release is soft-deleted too.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611005700_AddReleaseTypeAndTrackNumber")]
|
||||
partial class AddReleaseTypeAndTrackNumber
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("album");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseTypeAndTrackNumber : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "release_type",
|
||||
table: "track",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Single");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "track_number",
|
||||
table: "track",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "release_type",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "track_number",
|
||||
table: "track");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611164537_NormalizeReleaseTrack")]
|
||||
partial class NormalizeReleaseTrack
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NormalizeReleaseTrack : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1. Create the release table.
|
||||
migrationBuilder.CreateTable(
|
||||
name: "release",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
artist = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
genre = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
release_date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
image_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
release_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Single"),
|
||||
created_by_user_id = table.Column<long>(type: "bigint", nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_release", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_is_deleted",
|
||||
table: "release",
|
||||
column: "is_deleted");
|
||||
|
||||
// 2. Add the nullable FK column to track. A fresh column (not a rename of
|
||||
// created_by_user_id) so existing rows start with a null release until back-filled.
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "release_id",
|
||||
table: "track",
|
||||
type: "bigint",
|
||||
nullable: true);
|
||||
|
||||
// 3. Data migration — must run after the release table exists and release_id is added,
|
||||
// and before the release-cardinal columns are dropped from track (the SELECT reads them).
|
||||
// Create one release row per distinct (album, artist) from existing tracks, carrying
|
||||
// the release-cardinal fields. Tracks with a null album remain release_id = null.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO release (title, artist, genre, release_date, image_path, release_type,
|
||||
created_by_user_id, created_at, updated_at, is_deleted)
|
||||
SELECT DISTINCT ON (album, artist)
|
||||
album, artist, genre, release_date, image_path, release_type,
|
||||
created_by_user_id, NOW(), NOW(), false
|
||||
FROM track
|
||||
WHERE album IS NOT NULL
|
||||
ORDER BY album, artist, id;
|
||||
");
|
||||
|
||||
// Back-fill the FK: match each track to the release created from its (album, artist).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE track
|
||||
SET release_id = r.id
|
||||
FROM release r
|
||||
WHERE track.album = r.title
|
||||
AND track.artist = r.artist;
|
||||
");
|
||||
|
||||
// 4. Index + FK now that the column carries its back-filled values.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_track_release_id",
|
||||
table: "track",
|
||||
column: "release_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_track_release_release_id",
|
||||
table: "track",
|
||||
column: "release_id",
|
||||
principalTable: "release",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
// 5. Drop the now-migrated release-cardinal columns from track.
|
||||
migrationBuilder.DropColumn(name: "album", table: "track");
|
||||
migrationBuilder.DropColumn(name: "artist", table: "track");
|
||||
migrationBuilder.DropColumn(name: "genre", table: "track");
|
||||
migrationBuilder.DropColumn(name: "image_path", table: "track");
|
||||
migrationBuilder.DropColumn(name: "release_date", table: "track");
|
||||
migrationBuilder.DropColumn(name: "release_type", table: "track");
|
||||
migrationBuilder.DropColumn(name: "created_by_user_id", table: "track");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1. Re-add the track release-cardinal columns. artist is non-nullable with a default so
|
||||
// the add succeeds against existing rows before the back-fill repopulates it.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "album",
|
||||
table: "track",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "artist",
|
||||
table: "track",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "genre",
|
||||
table: "track",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "image_path",
|
||||
table: "track",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "release_date",
|
||||
table: "track",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "release_type",
|
||||
table: "track",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Single");
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "created_by_user_id",
|
||||
table: "track",
|
||||
type: "bigint",
|
||||
nullable: true);
|
||||
|
||||
// 2. Re-populate the track columns from the release join before the release table and FK go.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE track
|
||||
SET artist = r.artist,
|
||||
album = r.title,
|
||||
genre = r.genre,
|
||||
release_date = r.release_date,
|
||||
image_path = r.image_path,
|
||||
release_type = r.release_type,
|
||||
created_by_user_id = r.created_by_user_id
|
||||
FROM release r
|
||||
WHERE track.release_id = r.id;
|
||||
");
|
||||
|
||||
// 3. Drop the FK, index, the release_id column, and the release table.
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_track_release_release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_track_release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611184732_AddReleaseUniqueTitleArtist")]
|
||||
partial class AddReleaseUniqueTitleArtist
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseUniqueTitleArtist : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release",
|
||||
columns: new[] { "title", "artist" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260612000000_SoftDeleteOrphanedReleases")]
|
||||
partial class SoftDeleteOrphanedReleases
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Data-only migration: no schema change, snapshot unchanged.
|
||||
public partial class SoftDeleteOrphanedReleases : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Backfill: soft-delete any live release whose tracks were all soft-deleted before the
|
||||
// delete-cascade in UnifiedTrackService existed. These show as 0-track rows in the albums
|
||||
// browser; this clears the pre-existing orphans the cascade now prevents going forward.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE release
|
||||
SET is_deleted = true,
|
||||
updated_at = now()
|
||||
WHERE id IN (
|
||||
SELECT r.id
|
||||
FROM release r
|
||||
WHERE r.is_deleted = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM track t
|
||||
WHERE t.release_id = r.id
|
||||
AND t.is_deleted = false
|
||||
)
|
||||
);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("-- no-op: orphaned release soft-deletes are not rolled back");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ namespace DeepDrftData.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -31,11 +31,6 @@ namespace DeepDrftData.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("album");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -50,12 +45,6 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
@@ -72,14 +61,73 @@ namespace DeepDrftData.Migrations
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
@@ -87,6 +135,12 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@@ -96,8 +150,25 @@ namespace DeepDrftData.Migrations
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,36 @@ namespace DeepDrftData.Repositories;
|
||||
|
||||
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
{
|
||||
// The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) but no
|
||||
// DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference
|
||||
// to the injected context rather than reaching for a service locator — it is the same scoped
|
||||
// instance the base holds, so reads/writes stay in one unit of work.
|
||||
private readonly DeepDrftContext _context;
|
||||
|
||||
public TrackRepository(
|
||||
DeepDrftContext context,
|
||||
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
|
||||
IDbExceptionClassifier? classifier = null)
|
||||
: base(context, logger, classifier: classifier)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// Override base GetByIdAsync to include the Release navigation. Without this, the base
|
||||
// Query has no .Include, so Release is null on every entity (no lazy-loading proxies).
|
||||
public override async Task<TrackEntity?> GetByIdAsync(long id)
|
||||
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
// Override base GetAllAsync for the same reason — include Release so callers (e.g.
|
||||
// TrackManager.GetAll) receive fully-populated entities without a separate query.
|
||||
public override async Task<IEnumerable<TrackEntity>> GetAllAsync()
|
||||
=> await Query.Include(t => t.Release).ToListAsync();
|
||||
|
||||
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
|
||||
// uses Query (soft-delete filtered) rather than the raw DbSet.
|
||||
// uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the
|
||||
// converter can project the release-cardinal fields.
|
||||
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
|
||||
=> await Query.FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
||||
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
||||
|
||||
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
|
||||
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
|
||||
@@ -37,6 +55,7 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
|
||||
var index = Random.Shared.Next(count);
|
||||
return await Query
|
||||
.Include(t => t.Release)
|
||||
.OrderBy(t => t.Id)
|
||||
.Skip(index)
|
||||
.Take(1)
|
||||
@@ -53,27 +72,29 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
TrackFilter? filter,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<TrackEntity> query = Query;
|
||||
// Include Release so both the filter predicates and the converter can read release-cardinal
|
||||
// fields through the navigation.
|
||||
IQueryable<TrackEntity> query = Query.Include(t => t.Release);
|
||||
|
||||
if (filter is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
||||
{
|
||||
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
||||
// EF-translatable where ToLower().Contains() is not. Album is nullable — ILike on a
|
||||
// null column yields false, which is the desired "no match" behaviour.
|
||||
// EF-translatable where ToLower().Contains() is not. Artist/Title live on the joined
|
||||
// Release, which is null for loose tracks — guard the navigation before ILike.
|
||||
var pattern = $"%{filter.SearchText}%";
|
||||
query = query.Where(t =>
|
||||
EF.Functions.ILike(t.TrackName, pattern)
|
||||
|| EF.Functions.ILike(t.Artist, pattern)
|
||||
|| (t.Album != null && EF.Functions.ILike(t.Album, pattern)));
|
||||
|| (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern))
|
||||
|| (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Album))
|
||||
query = query.Where(t => t.Album == filter.Album);
|
||||
query = query.Where(t => t.Release != null && t.Release.Title == filter.Album);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
||||
query = query.Where(t => t.Genre == filter.Genre);
|
||||
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
@@ -99,30 +120,21 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
};
|
||||
}
|
||||
|
||||
// Distinct albums (non-null) with track counts and a representative cover key. The cover is the
|
||||
// first non-null ImagePath in the group; GroupBy + projection keeps it a single round-trip.
|
||||
public async Task<List<AlbumSummaryDto>> GetDistinctAlbumsAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.Album != null)
|
||||
.GroupBy(t => t.Album!)
|
||||
.Select(g => new AlbumSummaryDto
|
||||
{
|
||||
Album = g.Key,
|
||||
TrackCount = g.Count(),
|
||||
CoverImageKey = g
|
||||
.Where(t => t.ImagePath != null)
|
||||
.OrderBy(t => t.Id)
|
||||
.Select(t => t.ImagePath)
|
||||
.FirstOrDefault(),
|
||||
})
|
||||
.OrderBy(a => a.Album)
|
||||
// All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks.
|
||||
// The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto.
|
||||
public async Task<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.Where(r => !r.IsDeleted)
|
||||
.OrderBy(r => r.Title)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Distinct genres (non-null) with track counts.
|
||||
// Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks
|
||||
// (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks
|
||||
// (no release) carry no genre and are excluded.
|
||||
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.Genre != null)
|
||||
.GroupBy(t => t.Genre!)
|
||||
.Where(t => t.Release != null && t.Release.Genre != null)
|
||||
.GroupBy(t => t.Release!.Genre!)
|
||||
.Select(g => new GenreSummaryDto
|
||||
{
|
||||
Genre = g.Key,
|
||||
@@ -131,16 +143,69 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
.OrderBy(g => g.Genre)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against
|
||||
// GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out.
|
||||
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.ReleaseId != null)
|
||||
.GroupBy(t => t.ReleaseId!.Value)
|
||||
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
||||
|
||||
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
|
||||
// signalling the manager to create one. Soft-deleted releases never match.
|
||||
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
|
||||
string title, string artist, CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct);
|
||||
|
||||
// Persist a new release row and return it with its assigned Id. Lives here (not the manager)
|
||||
// because the repository owns the DbContext — the manager stays free of direct context access.
|
||||
public async Task<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||
{
|
||||
_context.Set<ReleaseEntity>().Add(release);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return release;
|
||||
}
|
||||
|
||||
// Load a tracked release by id so the manager can edit its fields in place and save. Returns
|
||||
// null when the id does not resolve (or the release is soft-deleted).
|
||||
public async Task<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct);
|
||||
|
||||
// Persist edits to a release. Update marks the whole entity modified, so it works whether the
|
||||
// instance is the change-tracked one from GetReleaseByIdAsync or a detached graph.
|
||||
public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||
{
|
||||
_context.Set<ReleaseEntity>().Update(release);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Soft-delete a release row in a single set-based UPDATE (no load round-trip). The !IsDeleted
|
||||
// guard makes a repeat call a no-op rather than re-stamping updated_at on an already-deleted row.
|
||||
public async Task SoftDeleteReleaseAsync(long id, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Set<ReleaseEntity>()
|
||||
.Where(r => r.Id == id && !r.IsDeleted)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.IsDeleted, true)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow), ct);
|
||||
}
|
||||
|
||||
// Count of non-deleted tracks on a single release. Backs the delete-cascade decision in
|
||||
// UnifiedTrackService: a release with zero live tracks after a delete is soft-deleted too.
|
||||
// Uses Query (soft-delete filtered) so just-deleted tracks are excluded from the count.
|
||||
public async Task<int> CountLiveTracksByReleaseAsync(long releaseId, CancellationToken ct = default)
|
||||
=> await Query.CountAsync(t => t.ReleaseId == releaseId, ct);
|
||||
|
||||
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
|
||||
{
|
||||
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
|
||||
target.EntryKey = source.EntryKey;
|
||||
target.TrackName = source.TrackName;
|
||||
target.Artist = source.Artist;
|
||||
target.Album = source.Album;
|
||||
target.Genre = source.Genre;
|
||||
target.ReleaseDate = source.ReleaseDate;
|
||||
target.ImagePath = source.ImagePath;
|
||||
target.CreatedByUserId = source.CreatedByUserId;
|
||||
target.TrackNumber = source.TrackNumber;
|
||||
target.OriginalFileName = source.OriginalFileName;
|
||||
target.ReleaseId = source.ReleaseId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,40 @@ namespace DeepDrftData;
|
||||
/// The DTO side mirrors the entity field-for-field; the audit columns
|
||||
/// (CreatedAt, UpdatedAt) come from BaseEntity / BaseModel.
|
||||
/// IsDeleted does not round-trip — soft-deleted rows are not exposed via the model.
|
||||
///
|
||||
/// Post Phase 8 §8.0: TrackEntity carries only track-cardinal fields plus a nullable
|
||||
/// ReleaseId/Release. The release-cardinal data converts through the Release maps below.
|
||||
/// </summary>
|
||||
public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
{
|
||||
public static ReleaseDto Convert(ReleaseEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
Title = entity.Title,
|
||||
Artist = entity.Artist,
|
||||
Genre = entity.Genre,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
ReleaseType = entity.ReleaseType,
|
||||
CreatedByUserId = entity.CreatedByUserId
|
||||
};
|
||||
|
||||
public static ReleaseEntity Convert(ReleaseDto dto) => new()
|
||||
{
|
||||
Id = dto.Id,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
UpdatedAt = dto.UpdatedAt,
|
||||
Title = dto.Title,
|
||||
Artist = dto.Artist,
|
||||
Genre = dto.Genre,
|
||||
ReleaseDate = dto.ReleaseDate,
|
||||
ImagePath = dto.ImagePath,
|
||||
ReleaseType = dto.ReleaseType,
|
||||
CreatedByUserId = dto.CreatedByUserId
|
||||
};
|
||||
|
||||
public static TrackDto Convert(TrackEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
@@ -19,15 +50,15 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
EntryKey = entity.EntryKey,
|
||||
TrackName = entity.TrackName,
|
||||
Artist = entity.Artist,
|
||||
Album = entity.Album,
|
||||
Genre = entity.Genre,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
CreatedByUserId = entity.CreatedByUserId,
|
||||
OriginalFileName = entity.OriginalFileName
|
||||
OriginalFileName = entity.OriginalFileName,
|
||||
TrackNumber = entity.TrackNumber,
|
||||
ReleaseId = entity.ReleaseId,
|
||||
Release = entity.Release is null ? null : Convert(entity.Release)
|
||||
};
|
||||
|
||||
// DTO → entity maps track-cardinal fields + ReleaseId only. The Release navigation is left
|
||||
// unset: the manager resolves/attaches the release row against the tracked context so a detached
|
||||
// graph never overwrites a shared release record.
|
||||
public static TrackEntity Convert(TrackDto model) => new()
|
||||
{
|
||||
Id = model.Id,
|
||||
@@ -35,12 +66,8 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
UpdatedAt = model.UpdatedAt,
|
||||
EntryKey = model.EntryKey,
|
||||
TrackName = model.TrackName,
|
||||
Artist = model.Artist,
|
||||
Album = model.Album,
|
||||
Genre = model.Genre,
|
||||
ReleaseDate = model.ReleaseDate,
|
||||
ImagePath = model.ImagePath,
|
||||
CreatedByUserId = model.CreatedByUserId,
|
||||
OriginalFileName = model.OriginalFileName
|
||||
OriginalFileName = model.OriginalFileName,
|
||||
TrackNumber = model.TrackNumber,
|
||||
ReleaseId = model.ReleaseId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Data.Errors;
|
||||
using Data.Managers;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
@@ -107,24 +108,27 @@ public class TrackManager
|
||||
Page = pageNumber,
|
||||
PageSize = pageSize,
|
||||
IsDescending = sortDescending,
|
||||
// Sorts navigate through the nullable Release relation; the null-coalescing
|
||||
// sentinels push loose tracks (no release) to the end, matching the prior
|
||||
// nulls-last behaviour on the flat columns.
|
||||
OrderBy = sortColumn switch
|
||||
{
|
||||
"TrackName" => e => e.TrackName,
|
||||
"Artist" => e => e.Artist,
|
||||
"Album" => e => (object)(e.Album ?? string.Empty),
|
||||
"Genre" => e => (object)(e.Genre ?? string.Empty),
|
||||
"ReleaseDate" => e => (object)(e.ReleaseDate ?? DateOnly.MaxValue),
|
||||
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
|
||||
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
|
||||
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
|
||||
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
|
||||
"TrackNumber" => e => e.TrackNumber,
|
||||
_ => e => e.Id
|
||||
}
|
||||
};
|
||||
|
||||
// An all-null filter must produce identical results to no filter, so collapse it to
|
||||
// null and take the unfiltered base path (preserves backward compatibility).
|
||||
// Always route through GetPagedFilteredAsync — it handles a null filter by skipping
|
||||
// all Where predicates, and it always includes Release. This removes the base-class
|
||||
// GetPagedAsync path, which has no .Include and would return entities with null Release.
|
||||
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
|
||||
|
||||
var page = effectiveFilter is null
|
||||
? await Repository.GetPagedAsync(parameters)
|
||||
: await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
|
||||
var page = await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
|
||||
|
||||
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
|
||||
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
|
||||
@@ -135,16 +139,64 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default)
|
||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var albums = await Repository.GetDistinctAlbumsAsync(cancellationToken);
|
||||
return ResultContainer<List<AlbumSummaryDto>>.CreatePassResult(albums);
|
||||
var releases = await Repository.GetReleasesAsync(cancellationToken);
|
||||
var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken);
|
||||
|
||||
var dtos = releases
|
||||
.Select(r =>
|
||||
{
|
||||
var dto = TrackConverter.Convert(r);
|
||||
dto.TrackCount = counts.GetValueOrDefault(r.Id);
|
||||
return dto;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return ResultContainer<List<ReleaseDto>>.CreatePassResult(dtos);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult(e.Message);
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (existing is not null)
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
|
||||
|
||||
// The natural key (title + artist) is authoritative — override whatever the caller put
|
||||
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
||||
var entity = TrackConverter.Convert(releaseData);
|
||||
entity.Id = 0;
|
||||
entity.Title = title;
|
||||
entity.Artist = artist;
|
||||
|
||||
try
|
||||
{
|
||||
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||
}
|
||||
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
|
||||
{
|
||||
// Concurrent upload inserted the same (title, artist) between our read and write.
|
||||
// Re-query and return the winning row. Should not return null here since the
|
||||
// constraint just fired, but re-throw if it does so the caller sees an error.
|
||||
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (race is null) throw;
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +217,22 @@ public class TrackManager
|
||||
{
|
||||
try
|
||||
{
|
||||
// A track with release context resolves (or creates) the shared release first so the FK
|
||||
// is set before insert. A standalone track (Release null) stays a loose track, ReleaseId
|
||||
// null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and
|
||||
// a populated ReleaseId, which falls straight through.
|
||||
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
|
||||
{
|
||||
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
|
||||
if (!resolved.Success || resolved.Value is null)
|
||||
{
|
||||
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
|
||||
return ResultContainer<TrackDto>.CreateFailResult(error);
|
||||
}
|
||||
|
||||
newTrack.ReleaseId = resolved.Value.Id;
|
||||
}
|
||||
|
||||
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
|
||||
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||
}
|
||||
@@ -181,6 +249,26 @@ public class TrackManager
|
||||
try
|
||||
{
|
||||
await Repository.UpdateAsync(TrackConverter.Convert(track));
|
||||
|
||||
// Release-cardinal edits flow through the linked release row, not the track. When the
|
||||
// track carries a Release payload and a resolved FK, load the tracked release, apply the
|
||||
// edited fields, and save. EntryKey/track fields are already persisted above.
|
||||
if (track.Release is { } release && track.ReleaseId is { } releaseId)
|
||||
{
|
||||
var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId);
|
||||
if (releaseEntity is not null)
|
||||
{
|
||||
releaseEntity.Title = release.Title;
|
||||
releaseEntity.Artist = release.Artist;
|
||||
releaseEntity.Genre = release.Genre;
|
||||
releaseEntity.ReleaseDate = release.ReleaseDate;
|
||||
releaseEntity.ImagePath = release.ImagePath;
|
||||
releaseEntity.ReleaseType = release.ReleaseType;
|
||||
releaseEntity.CreatedByUserId = release.CreatedByUserId;
|
||||
await Repository.UpdateReleaseAsync(releaseEntity);
|
||||
}
|
||||
}
|
||||
|
||||
var updated = await Repository.GetByIdAsync(track.Id);
|
||||
return updated is not null
|
||||
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
|
||||
@@ -194,4 +282,30 @@ public class TrackManager
|
||||
|
||||
// Delete(long) → Result is inherited from Manager<> and satisfies ITrackService.Delete
|
||||
// by signature. No override.
|
||||
|
||||
public async Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Repository.SoftDeleteReleaseAsync(id, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await Repository.CountLiveTracksByReleaseAsync(releaseId, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,125 @@
|
||||
@page "/"
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [Authorize]
|
||||
@layout Layout.CmsLayout
|
||||
@inject NavigationManager Nav
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ILogger<Index> Logger
|
||||
|
||||
<PageTitle>DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
||||
|
||||
<MudGrid Spacing="4">
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount)
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount)
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount)
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
private bool _tracksLoading = true;
|
||||
private bool _albumsLoading = true;
|
||||
private bool _genresLoading = true;
|
||||
|
||||
private int? _trackCount;
|
||||
private int? _albumCount;
|
||||
private int? _genreCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Nav.NavigateTo("/tracks");
|
||||
// Three independent reads run concurrently. Each loader calls StateHasChanged in its
|
||||
// finally block so its card updates as soon as its own fetch returns.
|
||||
await Task.WhenAll(LoadTrackCount(), LoadAlbumCount(), LoadGenreCount());
|
||||
}
|
||||
|
||||
private async Task LoadTrackCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetTrackCountAsync();
|
||||
_trackCount = result.Success ? result.Value : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard track count failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tracksLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAlbumCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetReleasesAsync();
|
||||
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard album summaries failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_albumsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadGenreCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetGenreSummariesAsync();
|
||||
_genreCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard genre summaries failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_genresLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment SummaryCard(string label, string icon, Color color, bool loading, int? count) => __builder =>
|
||||
{
|
||||
<MudCard Elevation="8" Style="height: 100%;">
|
||||
<MudCardContent>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
|
||||
<MudIcon Icon="@icon" Color="@color" Size="Size.Large" />
|
||||
@if (loading)
|
||||
{
|
||||
<MudProgressCircular Color="@color" Indeterminate="true" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h3" Color="@color">@(count?.ToString() ?? "—")</MudText>
|
||||
}
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@label</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
<MudCardActions Class="justify-center pb-4">
|
||||
<MudButton Variant="Variant.Text" Color="@color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
||||
OnClick="@(() => Nav.NavigateTo("/tracks"))">
|
||||
View
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
|
||||
<MudPaper Class="pa-6 mb-4" Elevation="2">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="AlbumName" ValueChanged="@((string v) => AlbumNameChanged.InvokeAsync(v))"
|
||||
T="string" Label="Album Name" Required="true" RequiredError="Album Name is required"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="Artist" ValueChanged="@((string v) => ArtistChanged.InvokeAsync(v))"
|
||||
T="string" Label="Artist" Required="true" RequiredError="Artist is required"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="Genre" ValueChanged="@((string v) => GenreChanged.InvokeAsync(v))"
|
||||
T="string" Label="Genre" Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="ReleaseDate" ValueChanged="@((string v) => ReleaseDateChanged.InvokeAsync(v))"
|
||||
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="ReleaseType" Value="ReleaseType" ValueChanged="@((ReleaseType v) => ReleaseTypeChanged.InvokeAsync(v))"
|
||||
Label="Release Type" Variant="Variant.Outlined" Disabled="Disabled">
|
||||
@foreach (var rt in Enum.GetValues<ReleaseType>())
|
||||
{
|
||||
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (SelectedImageFile is { } selectedImage)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="Disabled"
|
||||
OnClick="ClearSelectedFile"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else if (ExistingImagePreviewUrl is { } previewUrl)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudImage Src="@previewUrl"
|
||||
Alt="Current cover art"
|
||||
Elevation="1"
|
||||
Style="max-width: 120px; height: auto; border-radius: 4px;" />
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Current cover art.</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@Disabled" />
|
||||
@if (SelectedImageFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> AlbumNameChanged { get; set; }
|
||||
[Parameter] public string Artist { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
|
||||
[Parameter] public string Genre { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> GenreChanged { get; set; }
|
||||
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
|
||||
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
|
||||
|
||||
// BatchEdit only: when set (and no new file picked), preview the release's current cover.
|
||||
// The parent nulls this to drop the preview when the admin clears the existing cover.
|
||||
[Parameter] public string? ExistingImagePath { get; set; }
|
||||
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
|
||||
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser hits
|
||||
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses.
|
||||
private string? ExistingImagePreviewUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(ExistingImagePath)) return null;
|
||||
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
return baseAddress is null
|
||||
? null
|
||||
: new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(ExistingImagePath)}").ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
|
||||
SelectedImageFileChanged.InvokeAsync(e.File);
|
||||
|
||||
private Task ClearSelectedFile() =>
|
||||
SelectedImageFileChanged.InvokeAsync(null);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
@page "/tracks/album/{AlbumName}/edit"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject ILogger<BatchEdit> Logger
|
||||
|
||||
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_loadError is { } loadError)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mt-4">@loadError</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
@bind-SelectedImageFile="_selectedImageFile"
|
||||
ExistingImagePath="_existingImagePath"
|
||||
Disabled="_saving" />
|
||||
|
||||
@if (_existingImagePath is not null && _selectedImageFile is null)
|
||||
{
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
Disabled="_saving"
|
||||
OnClick="RemoveCover">
|
||||
Remove cover
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_saving"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||
Disabled="_saving"
|
||||
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
|
||||
Disabled="_saving">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SaveAsync"
|
||||
Disabled="@(_saving || _tracks.Count == 0)">
|
||||
@if (_saving)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Saving @_processedCount / @_tracks.Count…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Save Changes</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _loading = true;
|
||||
private string? _loadError;
|
||||
private bool _saving;
|
||||
private int _processedCount;
|
||||
private string? _errorMessage;
|
||||
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
private string? _existingImagePath;
|
||||
private bool _clearExistingImage;
|
||||
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// A single page of 100 covers the full release (albums are small — same assumption as
|
||||
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: AlbumName);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
_loadError = result.Messages.FirstOrDefault()?.Message ?? "Failed to load release.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var tracks = result.Value.Items.ToList();
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
_loadError = $"No tracks found for release '{AlbumName}'.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var release = tracks[0].Release;
|
||||
_albumName = AlbumName;
|
||||
_artist = release?.Artist ?? string.Empty;
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
||||
_existingImagePath = release?.ImagePath;
|
||||
|
||||
_tracks = tracks.Select(t => new BatchRowModel
|
||||
{
|
||||
Id = t.Id,
|
||||
EntryKey = t.EntryKey,
|
||||
OriginalFileName = t.OriginalFileName,
|
||||
TrackName = t.TrackName,
|
||||
TrackNumber = t.TrackNumber,
|
||||
WavFile = null,
|
||||
Status = BatchRowStatus.Queued
|
||||
}).ToList();
|
||||
|
||||
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
_errorMessage = null;
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
// New rows carry no Id — they take the upload path on save.
|
||||
_tracks.Add(new BatchRowModel
|
||||
{
|
||||
WavFile = file,
|
||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||
});
|
||||
}
|
||||
|
||||
if (_selectedIndex < 0 && _tracks.Count > 0)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveUp(int i)
|
||||
{
|
||||
if (i == 0) return;
|
||||
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i - 1;
|
||||
else if (_selectedIndex == i - 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void MoveDown(int i)
|
||||
{
|
||||
if (i == _tracks.Count - 1) return;
|
||||
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i + 1;
|
||||
else if (_selectedIndex == i + 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private async Task RemoveRow(int index)
|
||||
{
|
||||
var row = _tracks[index];
|
||||
if (row.Id.HasValue)
|
||||
{
|
||||
// Existing track — confirm before deleting.
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Remove track",
|
||||
$"Remove '{row.TrackName}' from this release? This deletes the track permanently.",
|
||||
yesText: "Remove", cancelText: "Cancel");
|
||||
if (confirmed != true) return;
|
||||
|
||||
var result = await CmsTrackService.DeleteTrackAsync(row.Id.Value);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// New track (not yet uploaded) or confirmed existing delete — remove from list.
|
||||
_tracks.RemoveAt(index);
|
||||
if (index < _selectedIndex) _selectedIndex--;
|
||||
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||
}
|
||||
|
||||
private void RemoveCover()
|
||||
{
|
||||
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
|
||||
// the existing path here drops the preview in AlbumHeaderFields.
|
||||
_clearExistingImage = true;
|
||||
_existingImagePath = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_albumName))
|
||||
{
|
||||
_errorMessage = "Album Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tracks.Count == 0)
|
||||
{
|
||||
_errorMessage = "A release must have at least one track.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
// New rows (no Id) need a WAV; existing rows keep their vault audio.
|
||||
foreach (var t in _tracks)
|
||||
{
|
||||
if (!t.Id.HasValue && t.WavFile is null)
|
||||
{
|
||||
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// [Authorize]/Admin-gated page — an unparseable id here is a configuration bug.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
DateOnly? releaseDate = string.IsNullOrWhiteSpace(_releaseDate)
|
||||
? null
|
||||
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
|
||||
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
|
||||
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
|
||||
|
||||
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
||||
_saving = true;
|
||||
_processedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Upload any newly picked cover art once; abort if it fails so we never point metadata
|
||||
// at an image that was never stored.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
// Tri-state cover for UpdateAsync: a freshly uploaded path sets it; an explicit clear
|
||||
// sends ""; otherwise null leaves the existing cover untouched.
|
||||
string? imagePathForUpdate =
|
||||
_imagePath is { } newPath ? newPath
|
||||
: _clearExistingImage ? ""
|
||||
: null;
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
for (int i = 0; i < _tracks.Count; i++)
|
||||
{
|
||||
var row = _tracks[i];
|
||||
|
||||
if (row.Status == BatchRowStatus.Done)
|
||||
{
|
||||
_processedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var trackNumber = i + 1; // 1-based ordinal from list position
|
||||
|
||||
row.Status = BatchRowStatus.Uploading;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
if (row.Id.HasValue)
|
||||
{
|
||||
// Existing track — metadata-only update; audio stays in the vault.
|
||||
var updateResult = await CmsTrackService.UpdateAsync(
|
||||
row.Id.Value,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
releaseDate,
|
||||
imagePathForUpdate,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
var error = updateResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch edit: update for '{TrackName}' (id={Id}) failed: {Error}",
|
||||
row.TrackName, row.Id.Value, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New track — upload, then link cover art with a follow-up update (same
|
||||
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
|
||||
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
|
||||
var uploadResult = await CmsTrackService.UploadTrackAsync(
|
||||
wavStream,
|
||||
row.WavFile.Name,
|
||||
row.WavFile.ContentType,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!uploadResult.Success || uploadResult.Value is null)
|
||||
{
|
||||
var error = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch edit: upload for new track '{TrackName}' failed: {Error}",
|
||||
row.TrackName, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Link a cover only when one is actively set ("" clear doesn't apply to
|
||||
// a brand-new track that has no cover yet).
|
||||
if (imagePathForUpdate is { Length: > 0 } linkPath)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
uploadResult.Value.Id,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
releaseDate,
|
||||
linkPath,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track persisted; cover can be linked via TrackEdit.
|
||||
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
||||
row.TrackName, uploadResult.Value.Id);
|
||||
}
|
||||
}
|
||||
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Batch edit: exception processing '{TrackName}'", row.TrackName);
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = "Save failed — please try again.";
|
||||
failed++;
|
||||
}
|
||||
|
||||
_processedCount++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Either branch changed catalogue data, so the browse caches are stale regardless of
|
||||
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
|
||||
// album and genre lists re-fetch.
|
||||
VM.Invalidate();
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
||||
Navigation.NavigateTo("/tracks/albums");
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Saved {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||
// Stay on page so the admin can see the failed rows.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
|
||||
/// <summary>
|
||||
/// A single track row shared by <c>BatchUpload</c> (all rows are new uploads) and
|
||||
/// <c>BatchEdit</c> (existing rows carry <see cref="Id"/>; admins may also add new upload rows).
|
||||
/// </summary>
|
||||
public class BatchRowModel
|
||||
{
|
||||
/// <summary>SQL id of an existing track. <c>null</c> means a new row to upload.</summary>
|
||||
public long? Id { get; set; }
|
||||
|
||||
/// <summary>Vault entry key — existing rows only.</summary>
|
||||
public string? EntryKey { get; set; }
|
||||
|
||||
/// <summary>Original upload filename — existing rows only, read-only display.</summary>
|
||||
public string? OriginalFileName { get; set; }
|
||||
|
||||
/// <summary>Selected WAV — new rows only.</summary>
|
||||
public IBrowserFile? WavFile { get; set; }
|
||||
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
|
||||
public int TrackNumber { get; set; }
|
||||
|
||||
public BatchRowStatus Status { get; set; } = BatchRowStatus.Queued;
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
|
||||
@@ -0,0 +1,60 @@
|
||||
@if (SelectedTrack is null)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Color="Color.Default">Select a track from the list to edit its details.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
<MudTextField Value="SelectedTrack.TrackName"
|
||||
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
|
||||
T="string"
|
||||
Label="Track Name"
|
||||
Required="true"
|
||||
RequiredError="Track Name is required"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="Disabled" />
|
||||
|
||||
@if (SelectedTrack.Id.HasValue)
|
||||
{
|
||||
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">Existing track — audio is not editable.</MudText>
|
||||
</MudField>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudField Label="WAV File" Variant="Variant.Outlined" InnerPadding="false">
|
||||
@if (SelectedTrack.WavFile is { } wav)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@wav.Name (@FormatBytes(wav.Size))</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Error">No WAV file selected.</MudText>
|
||||
}
|
||||
</MudField>
|
||||
}
|
||||
|
||||
@if (SelectedTrack.Status == BatchRowStatus.Failed)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@SelectedTrack.ErrorMessage</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public BatchRowModel? SelectedTrack { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public EventCallback<string> TrackNameChanged { get; set; }
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
const long KB = 1024;
|
||||
const long MB = KB * 1024;
|
||||
const long GB = MB * 1024;
|
||||
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
|
||||
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
|
||||
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
|
||||
return $"{bytes} bytes";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" GutterBottom="true">Tracks</MudText>
|
||||
|
||||
@if (AllowNewTracks)
|
||||
{
|
||||
<InputFile OnChange="HandleWavFilesSelected" accept=".wav,audio/wav,audio/x-wav" multiple disabled="@Disabled" />
|
||||
}
|
||||
|
||||
@if (Tracks.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default" Class="mt-3">No tracks added yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="BatchRowModel" Class="mt-3">
|
||||
@for (var i = 0; i < Tracks.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var row = Tracks[index];
|
||||
<div style="@RowStyle(index)" @onclick="() => SelectRow(index)">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="pa-2">
|
||||
<MudText Typo="Typo.body2" Style="min-width: 1.5rem;">@(index + 1).</MudText>
|
||||
<MudText Typo="Typo.body2" Style="flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@row.TrackName</MudText>
|
||||
@StatusChip(row)
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
||||
Size="Size.Small"
|
||||
Disabled="@(index == 0 || Disabled)"
|
||||
OnClick="@(() => OnMoveUp.InvokeAsync(index))"
|
||||
aria-label="Move track up" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
||||
Size="Size.Small"
|
||||
Disabled="@(index == Tracks.Count - 1 || Disabled)"
|
||||
OnClick="@(() => OnMoveDown.InvokeAsync(index))"
|
||||
aria-label="Move track down" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="@Disabled"
|
||||
OnClick="@(() => OnRemove.InvokeAsync(index))"
|
||||
aria-label="Remove track" />
|
||||
</MudStack>
|
||||
</div>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<BatchRowModel> Tracks { get; set; } = new();
|
||||
[Parameter] public int SelectedIndex { get; set; }
|
||||
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNewTracks { get; set; } = true;
|
||||
[Parameter] public EventCallback<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMoveUp { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMoveDown { get; set; }
|
||||
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||
|
||||
private const int MaxFilesPerPick = 50;
|
||||
|
||||
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
|
||||
|
||||
private Task HandleWavFilesSelected(InputFileChangeEventArgs e) =>
|
||||
OnWavFilesSelected.InvokeAsync(e.GetMultipleFiles(MaxFilesPerPick));
|
||||
|
||||
private string RowStyle(int index)
|
||||
{
|
||||
const string baseStyle = "cursor: pointer; border-radius: 4px;";
|
||||
return index == SelectedIndex
|
||||
? $"{baseStyle} background-color: var(--mud-palette-action-default-hover);"
|
||||
: baseStyle;
|
||||
}
|
||||
|
||||
private RenderFragment StatusChip(BatchRowModel row) => row.Status switch
|
||||
{
|
||||
BatchRowStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
|
||||
BatchRowStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
|
||||
BatchRowStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
|
||||
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
@page "/tracks/upload"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<BatchUpload> Logger
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
|
||||
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
|
||||
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
@bind-SelectedImageFile="_selectedImageFile"
|
||||
Disabled="_uploading" />
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_uploading"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||
Disabled="_uploading"
|
||||
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
|
||||
Disabled="_uploading">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SubmitAsync"
|
||||
Disabled="@(_uploading || _tracks.Count == 0)">
|
||||
@if (_uploading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Uploading @_uploadedCount / @_tracks.Count…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Upload Release</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _uploading;
|
||||
private int _uploadedCount;
|
||||
private string? _errorMessage;
|
||||
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
_errorMessage = null;
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
_tracks.Add(new BatchRowModel
|
||||
{
|
||||
WavFile = file,
|
||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||
});
|
||||
}
|
||||
|
||||
if (_selectedIndex < 0 && _tracks.Count > 0)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveUp(int i)
|
||||
{
|
||||
if (i == 0) return;
|
||||
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i - 1;
|
||||
else if (_selectedIndex == i - 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void MoveDown(int i)
|
||||
{
|
||||
if (i == _tracks.Count - 1) return;
|
||||
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i + 1;
|
||||
else if (_selectedIndex == i + 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void RemoveRow(int i)
|
||||
{
|
||||
_tracks.RemoveAt(i);
|
||||
if (i < _selectedIndex) _selectedIndex--;
|
||||
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_albumName))
|
||||
{
|
||||
_errorMessage = "Album Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tracks.Count == 0)
|
||||
{
|
||||
_errorMessage = "Add at least one track.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var t in _tracks)
|
||||
{
|
||||
if (t.WavFile is null)
|
||||
{
|
||||
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// The page is gated by [Authorize] under the Admin role, so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
||||
_uploading = true;
|
||||
_uploadedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Upload any selected cover art once; abort the submit if it fails so we never
|
||||
// create tracks expecting an image that was never stored in the vault.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
for (int i = 0; i < _tracks.Count; i++)
|
||||
{
|
||||
var row = _tracks[i];
|
||||
var trackNumber = i + 1; // 1-based ordinal from list position
|
||||
|
||||
row.Status = BatchRowStatus.Uploading;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in StreamContent so the whole file is never materialised in
|
||||
// memory before DeepDrftAPI receives it.
|
||||
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
|
||||
var result = await CmsTrackService.UploadTrackAsync(
|
||||
wavStream,
|
||||
row.WavFile.Name,
|
||||
row.WavFile.ContentType,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch upload: track '{TrackName}' failed: {Error}", row.TrackName, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with
|
||||
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
|
||||
if (_imagePath is { } imgPath)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
result.Value.Id,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
|
||||
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
|
||||
row.TrackName, result.Value.Id);
|
||||
}
|
||||
}
|
||||
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName);
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = "Upload failed — please try again.";
|
||||
failed++;
|
||||
}
|
||||
|
||||
_uploadedCount++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
|
||||
VM.Invalidate();
|
||||
Navigation.NavigateTo("/tracks");
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||
// Stay on page so the admin can see the failed rows.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_uploading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsAlbumBrowser> Logger
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No releases found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable T="AlbumRow"
|
||||
Items="_rows"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%;"></MudTh>
|
||||
<MudTh Style="width: 1%;">Art</MudTh>
|
||||
<MudTh>Album</MudTh>
|
||||
<MudTh>Artist</MudTh>
|
||||
<MudTh>Genre</MudTh>
|
||||
<MudTh>Release Date</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@(context.IsExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => ToggleExpand(context))" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Art">
|
||||
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
|
||||
{
|
||||
<div class="cms-album-thumb"
|
||||
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Album">@context.Release.Title</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Type">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@context.Release.ReleaseType</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Batch Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/album/{Uri.EscapeDataString(context.Release.Title)}/edit")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete release">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
Disabled="@context.IsDeleting"
|
||||
OnClick="@(() => ConfirmAndDeleteAlbum(context))" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<ChildRowContent>
|
||||
@if (context.IsExpanded)
|
||||
{
|
||||
<MudTr>
|
||||
<MudTd colspan="9" Style="padding: 0;">
|
||||
@if (context.IsLoading)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Typo="Typo.body2">Loading tracks…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else if (context.Tracks is { Count: 0 })
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="pa-4">No tracks found.</MudText>
|
||||
}
|
||||
else if (context.Tracks is not null)
|
||||
{
|
||||
<MudTable T="TrackDto" Items="context.Tracks" Context="track" Dense="true" Hover="false"
|
||||
Elevation="0" Style="background: transparent;">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
||||
<MudTh>Track Name</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudTd>
|
||||
</MudTr>
|
||||
}
|
||||
</ChildRowContent>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ReleaseDto> Releases { get; set; } = Array.Empty<ReleaseDto>();
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
private List<AlbumRow> _rows = new();
|
||||
|
||||
// Tracks the Releases reference last projected into _rows. Guards against OnParametersSet
|
||||
// resurrecting a row we removed locally on delete: VM.Albums is cached for the circuit and is
|
||||
// not re-fetched after a delete, so a blind rebuild every render would bring the deleted album
|
||||
// back. We only re-project when the parent hands us a genuinely new list.
|
||||
private IReadOnlyList<ReleaseDto>? _projectedReleases;
|
||||
|
||||
// The cover-art endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated,
|
||||
// so the browser hits it directly. Base address comes from the same named client the CMS uses.
|
||||
private Uri? _contentApiBase;
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
|
||||
// Re-project rows only when the parent supplies a genuinely new release list (reference change).
|
||||
// Local edits to _rows (a removed row after delete) must survive re-renders triggered by the
|
||||
// same cached VM.Albums instance.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_projectedReleases, Releases))
|
||||
{
|
||||
_projectedReleases = Releases;
|
||||
_rows = Releases.Select(r => new AlbumRow { Release = r }).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private string? ThumbUrl(string imagePath) =>
|
||||
_contentApiBase is null
|
||||
? null
|
||||
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
|
||||
|
||||
private async Task ToggleExpand(AlbumRow row)
|
||||
{
|
||||
row.IsExpanded = !row.IsExpanded;
|
||||
if (row.IsExpanded && row.Tracks is null && !row.IsLoading)
|
||||
{
|
||||
row.IsLoading = true;
|
||||
StateHasChanged();
|
||||
row.Tracks = await LoadTracksAsync(row.Release.Title);
|
||||
row.IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Albums are small releases; a single page of 100 always covers the full track list (see brief).
|
||||
private async Task<List<TrackDto>> LoadTracksAsync(string albumTitle)
|
||||
{
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: albumTitle);
|
||||
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
return result.Value.Items.ToList();
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks for '{albumTitle}': {error}", Severity.Error);
|
||||
return new List<TrackDto>();
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDeleteAlbum(AlbumRow row)
|
||||
{
|
||||
// Need track IDs to delete; load them if the row was never expanded.
|
||||
row.Tracks ??= await LoadTracksAsync(row.Release.Title);
|
||||
var tracks = row.Tracks;
|
||||
var count = tracks.Count;
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
// Orphaned release: every track was soft-deleted earlier, leaving a 0-track row that
|
||||
// cannot be cleared by deleting tracks. Delete the release record directly instead.
|
||||
await ConfirmAndDeleteEmptyReleaseAsync(row);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete release",
|
||||
markupMessage: new MarkupString(
|
||||
$"Delete all <strong>{count}</strong> track(s) in <strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong>? This removes metadata and audio for every track."),
|
||||
yesText: "Delete all",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
row.IsDeleting = true;
|
||||
StateHasChanged();
|
||||
|
||||
var failures = 0;
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var del = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (!del.Success) failures++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
row.IsDeleting = false;
|
||||
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
|
||||
_rows.Remove(row);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"{count - failures} of {count} track(s) deleted; {failures} failed.", Severity.Warning);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade
|
||||
// delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the
|
||||
// parent so the cached VM.Albums stays in sync with what is shown.
|
||||
private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete release",
|
||||
markupMessage: new MarkupString(
|
||||
$"<strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong> has no tracks. Delete this empty release record?"),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
row.IsDeleting = true;
|
||||
StateHasChanged();
|
||||
|
||||
var result = await CmsTrackService.DeleteReleaseAsync(row.Release.Id);
|
||||
|
||||
row.IsDeleting = false;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
|
||||
_rows.Remove(row);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private sealed class AlbumRow
|
||||
{
|
||||
public required ReleaseDto Release { get; init; }
|
||||
public List<TrackDto>? Tracks { get; set; } // null = not yet loaded
|
||||
public bool IsExpanded { get; set; }
|
||||
public bool IsLoading { get; set; }
|
||||
public bool IsDeleting { get; set; }
|
||||
|
||||
// Server-projected count from GetReleasesAsync. Drives the Tracks column without a lazy load.
|
||||
public int TrackCount => Release.TrackCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.cms-album-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-album-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@using DeepDrftModels.DTOs
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (Genres.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No genres found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
@foreach (var genre in Genres)
|
||||
{
|
||||
var isExpanded = ExpandedGenre == genre.Genre;
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="@(isExpanded ? 4 : 1)"
|
||||
Style="cursor: pointer;"
|
||||
@onclick="@(() => ToggleGenre(genre.Genre))">
|
||||
<div class="@SwatchClass(isExpanded)"></div>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6">@genre.Genre</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">@genre.TrackCount track(s)</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@if (ExpandedGenre is not null)
|
||||
{
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@ExpandedGenre</MudText>
|
||||
<CmsTrackGrid @key="ExpandedGenre" GenreFilter="@ExpandedGenre" ShowAddButton="false" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<GenreSummaryDto> Genres { get; set; } = Array.Empty<GenreSummaryDto>();
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public string? ExpandedGenre { get; set; }
|
||||
[Parameter] public EventCallback<string?> OnExpandedGenreChanged { get; set; }
|
||||
|
||||
// The view model owns the toggle (selecting the open genre collapses it), so we pass the raw
|
||||
// clicked genre rather than pre-computing the next state here — keeps the toggle logic single-sourced.
|
||||
private async Task ToggleGenre(string genre) =>
|
||||
await OnExpandedGenreChanged.InvokeAsync(genre);
|
||||
|
||||
private static string SwatchClass(bool isExpanded) =>
|
||||
isExpanded ? "cms-genre-swatch cms-genre-swatch--active" : "cms-genre-swatch";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.cms-genre-swatch {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cms-genre-swatch--active {
|
||||
background-color: var(--mud-palette-primary-hover);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsTrackGrid> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@if (ShowAddButton)
|
||||
{
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/tracks/upload">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
<MudTable T="TrackDto"
|
||||
@ref="_table"
|
||||
ServerData="LoadServerData"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true"
|
||||
RowsPerPage="@PageSize"
|
||||
AllowUnsorted="false">
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
||||
</LoadingContent>
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Track #</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Art</MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Track #">@context.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Art">
|
||||
@if (!string.IsNullOrEmpty(context.Release?.ImagePath))
|
||||
{
|
||||
<div class="cms-track-thumb"
|
||||
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-track-thumb cms-track-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Waveform">
|
||||
@if (HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/{context.Id}")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
||||
</MudTooltip>
|
||||
<MudTooltip>
|
||||
<TooltipContent>
|
||||
<div class="cms-track-info">
|
||||
<div>Entry: @context.EntryKey</div>
|
||||
<div>File: @(context.OriginalFileName ?? "—")</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
<ChildContent>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.Info" Size="Size.Small" />
|
||||
</ChildContent>
|
||||
</MudTooltip>
|
||||
@if (!HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="Generate Waveform">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(_bulkRunning || _generating.Contains(context.EntryKey))"
|
||||
OnClick="@(() => GenerateOneAsync(context))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? AlbumFilter { get; set; }
|
||||
[Parameter] public string? GenreFilter { get; set; }
|
||||
[Parameter] public bool ShowAddButton { get; set; } = true;
|
||||
[Parameter] public int PageSize { get; set; } = 20;
|
||||
[Parameter] public EventCallback OnTracksChanged { get; set; }
|
||||
[Parameter] public EventCallback OnStatusLoaded { get; set; }
|
||||
|
||||
private MudTable<TrackDto>? _table;
|
||||
|
||||
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
|
||||
private Dictionary<string, bool> _waveformStatus = new();
|
||||
private readonly HashSet<string> _generating = new();
|
||||
|
||||
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
|
||||
private bool _bulkRunning;
|
||||
|
||||
// The image endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated, so
|
||||
// the browser hits it directly. Base address comes from the same named client the CMS uses.
|
||||
private Uri? _contentApiBase;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
await RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
private bool HasProfile(string entryKey) =>
|
||||
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
|
||||
|
||||
private string? ThumbUrl(string imagePath) =>
|
||||
_contentApiBase is null
|
||||
? null
|
||||
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
|
||||
|
||||
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
|
||||
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
|
||||
/// the per-row icons reflect the new state.
|
||||
/// </summary>
|
||||
public async Task RefreshWaveformStatusAsync()
|
||||
{
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
_waveformStatus = result.Success && result.Value is not null
|
||||
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
|
||||
: new Dictionary<string, bool>();
|
||||
|
||||
StateHasChanged();
|
||||
await OnStatusLoaded.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Set by the parent while its bulk generate runs so per-row buttons disable.</summary>
|
||||
public void SetBulkRunning(bool running)
|
||||
{
|
||||
_bulkRunning = running;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
|
||||
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
|
||||
var sortDescending = state.SortDirection == SortDirection.Descending;
|
||||
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
pageNumber, state.PageSize, sortColumn, sortDescending,
|
||||
AlbumFilter, GenreFilter, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
|
||||
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
||||
}
|
||||
|
||||
var page = result.Value;
|
||||
return new TableData<TrackDto>
|
||||
{
|
||||
Items = page.Items,
|
||||
TotalItems = page.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDelete(TrackDto track)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete track",
|
||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
||||
if (_table is not null) await _table.ReloadServerData();
|
||||
await OnTracksChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateOneAsync(TrackDto track)
|
||||
{
|
||||
_generating.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
_waveformStatus[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generating.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.cms-track-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-track-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
|
||||
.cms-track-info {
|
||||
font-family: monospace;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/tracks/{Id:long}"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@@ -62,6 +64,20 @@
|
||||
Label="Genre"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudSelect @bind-Value="_form.ReleaseType"
|
||||
Label="Release Type"
|
||||
Variant="Variant.Outlined">
|
||||
@foreach (var releaseType in Enum.GetValues<ReleaseType>())
|
||||
{
|
||||
<MudSelectItem Value="releaseType">@releaseType</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudNumericField @bind-Value="_form.TrackNumber"
|
||||
Label="Track Number"
|
||||
Min="1"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (ImagePreviewUrl is { } previewUrl)
|
||||
@@ -207,9 +223,14 @@
|
||||
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
|
||||
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
|
||||
releaseDate,
|
||||
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath);
|
||||
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
|
||||
_form.ReleaseType,
|
||||
_form.TrackNumber);
|
||||
if (updated.Success)
|
||||
{
|
||||
// Album/genre browse lists derive from this track's metadata; drop their cache so
|
||||
// the /tracks browser re-fetches fresh data on next mode switch.
|
||||
VM.Invalidate();
|
||||
Snackbar.Add("Track updated.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
@@ -247,7 +268,7 @@
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Delete track",
|
||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Artist}? This cannot be undone.",
|
||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.",
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
@@ -259,6 +280,9 @@
|
||||
var result = await CmsTrackService.DeleteTrackAsync(Id);
|
||||
if (result.Success)
|
||||
{
|
||||
// Deleting a track can empty or alter a release; drop the browse cache so the
|
||||
// /tracks album and genre lists re-fetch fresh counts on next mode switch.
|
||||
VM.Invalidate();
|
||||
Snackbar.Add("Track deleted.", Severity.Success);
|
||||
Nav.NavigateTo("/tracks");
|
||||
}
|
||||
@@ -287,17 +311,21 @@
|
||||
public string? Genre { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public DateTime? ReleaseDate { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
|
||||
public static TrackEditForm From(TrackDto track) => new()
|
||||
{
|
||||
TrackName = track.TrackName,
|
||||
Artist = track.Artist,
|
||||
Album = track.Album,
|
||||
Genre = track.Genre,
|
||||
ImagePath = track.ImagePath,
|
||||
ReleaseDate = track.ReleaseDate is { } d
|
||||
Artist = track.Release?.Artist ?? string.Empty,
|
||||
Album = track.Release?.Title,
|
||||
Genre = track.Release?.Genre,
|
||||
ImagePath = track.Release?.ImagePath,
|
||||
ReleaseDate = track.Release?.ReleaseDate is { } d
|
||||
? d.ToDateTime(TimeOnly.MinValue)
|
||||
: null
|
||||
: null,
|
||||
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
|
||||
TrackNumber = track.TrackNumber
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,27 @@
|
||||
@page "/tracks"
|
||||
@using System.Net
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/genres"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@attribute [Authorize]
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackList> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h3" Class="mb-4">Tracks</MudText>
|
||||
|
||||
<MudTabs Elevation="0" Rounded="false" ApplyEffectsToContainer="true" PanelClass="pt-4">
|
||||
<MudTabPanel Text="Tracks" Icon="@Icons.Material.Filled.LibraryMusic">
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/tracks/new">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTable T="TrackDto"
|
||||
@ref="_table"
|
||||
ServerData="LoadServerData"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true"
|
||||
RowsPerPage="20"
|
||||
AllowUnsorted="false">
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
||||
</LoadingContent>
|
||||
<HeaderContent>
|
||||
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
|
||||
<MudTh>Entry Key</MudTh>
|
||||
<MudTh>File Name</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Artist</MudTd>
|
||||
<MudTd DataLabel="Album">@(context.Album ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
|
||||
<MudTd DataLabel="File Name"><MudText Typo="Typo.caption" Style="font-family: monospace;">@(context.OriginalFileName ?? "—")</MudText></MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/{context.Id}")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="Waveform Pre-Processing" Icon="@Icons.Material.Filled.GraphicEq">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5">Waveform Pre-Processing</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">
|
||||
Generate loudness profiles for tracks that predate the waveform seeker.
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
<MudText Typo="Typo.h3">Tracks</MudText>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||
Disabled="@(_bulkRunning || _missingCount == 0)"
|
||||
OnClick="GenerateAllMissing">
|
||||
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
||||
OnClick="GenerateAllMissingAsync">
|
||||
@if (_bulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
@@ -99,184 +29,105 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Generate All Missing (@_missingCount)</span>
|
||||
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
|
||||
}
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
<MudTable T="WaveformStatusDto"
|
||||
Items="_waveformRows"
|
||||
Loading="_waveformLoading"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true">
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText Typo="Typo.body1">Loading waveform status…</MudText>
|
||||
</LoadingContent>
|
||||
<HeaderContent>
|
||||
<MudTh>Track Name</MudTh>
|
||||
<MudTh>Entry Key</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||
<MudTd DataLabel="Entry Key">
|
||||
<MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Profile">
|
||||
@if (context.HasProfile)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text"
|
||||
Icon="@Icons.Material.Filled.CheckCircle">Stored</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Text"
|
||||
Icon="@Icons.Material.Filled.Cancel">Missing</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
@if (!context.HasProfile)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
<MudToggleGroup T="BrowseMode"
|
||||
Value="VM.Mode"
|
||||
ValueChanged="OnModeChanged"
|
||||
SelectionMode="SelectionMode.SingleSelection"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||
Disabled="@(_bulkRunning || IsGenerating(context.EntryKey))"
|
||||
OnClick="@(() => GenerateOne(context))">
|
||||
@if (IsGenerating(context.EntryKey))
|
||||
Size="Size.Small"
|
||||
Class="mb-4">
|
||||
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Genres">Genres</MudToggleItem>
|
||||
</MudToggleGroup>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating…</span>
|
||||
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
|
||||
}
|
||||
else if (VM.Mode == BrowseMode.Albums)
|
||||
{
|
||||
<CmsAlbumBrowser Releases="VM.Albums"
|
||||
IsLoading="VM.AlbumsLoading"
|
||||
OnReleasesChanged="OnAlbumsChanged" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Generate</span>
|
||||
<CmsGenreBrowser Genres="VM.Genres"
|
||||
IsLoading="VM.GenresLoading"
|
||||
ExpandedGenre="@VM.ExpandedGenre"
|
||||
OnExpandedGenreChanged="OnExpandedGenreChanged" />
|
||||
}
|
||||
</MudButton>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// Track list fields
|
||||
private MudTable<TrackDto>? _table;
|
||||
private CmsTrackGrid? _grid;
|
||||
|
||||
// Waveform fields
|
||||
private List<WaveformStatusDto> _waveformRows = new();
|
||||
private readonly HashSet<string> _generating = new();
|
||||
private bool _waveformLoading = true;
|
||||
// The album browser owns its own row state and removes a deleted release locally. Invalidate the
|
||||
// VM cache so genres and album counts reflect the deletion on next mode switch.
|
||||
private void OnAlbumsChanged()
|
||||
{
|
||||
VM.Invalidate();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Local state for the parent-owned "Generate All Missing" bulk run.
|
||||
private bool _bulkRunning;
|
||||
private int _bulkTotal;
|
||||
private int _bulkDone;
|
||||
|
||||
private int _missingCount => _waveformRows.Count(r => !r.HasProfile);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadWaveformStatus();
|
||||
var uri = NavigationManager.Uri;
|
||||
var initial = uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase)
|
||||
? BrowseMode.Albums
|
||||
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase)
|
||||
? BrowseMode.Genres
|
||||
: BrowseMode.Tracks;
|
||||
await VM.SwitchModeAsync(initial);
|
||||
}
|
||||
|
||||
// ── Track list methods ──────────────────────────────────────────────────
|
||||
|
||||
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
|
||||
private async Task OnModeChanged(BrowseMode mode)
|
||||
{
|
||||
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
|
||||
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
|
||||
var sortDescending = state.SortDirection == SortDirection.Descending;
|
||||
|
||||
var result = await CmsTrackService.GetPagedAsync(pageNumber, state.PageSize, sortColumn, sortDescending, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
await VM.SwitchModeAsync(mode);
|
||||
var path = mode switch
|
||||
{
|
||||
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
|
||||
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
||||
}
|
||||
|
||||
var page = result.Value;
|
||||
return new TableData<TrackDto>
|
||||
{
|
||||
Items = page.Items,
|
||||
TotalItems = page.TotalCount
|
||||
BrowseMode.Albums => "/tracks/albums",
|
||||
BrowseMode.Genres => "/tracks/genres",
|
||||
_ => "/tracks"
|
||||
};
|
||||
NavigationManager.NavigateTo(path, replace: true);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDelete(TrackDto track)
|
||||
private void OnExpandedGenreChanged(string? genre)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete track",
|
||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Artist)}? This removes both the metadata row and the underlying audio entry."),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
||||
if (_table is not null) await _table.ReloadServerData();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
VM.SetExpandedGenre(genre);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// ── Waveform pre-processing methods ────────────────────────────────────
|
||||
|
||||
private async Task LoadWaveformStatus()
|
||||
/// <summary>
|
||||
/// Backfill every track missing a waveform profile, one request at a time so a large backfill
|
||||
/// does not flood the API with concurrent WAV decodes. On completion, refreshes the grid's
|
||||
/// status map so the per-row icons reflect the new state.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingAsync()
|
||||
{
|
||||
_waveformLoading = true;
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
_waveformLoading = false;
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
|
||||
if (!statusResult.Success || statusResult.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
|
||||
_waveformRows = new List<WaveformStatusDto>();
|
||||
return;
|
||||
}
|
||||
|
||||
_waveformRows = result.Value.OrderBy(r => r.HasProfile).ThenBy(r => r.TrackName).ToList();
|
||||
}
|
||||
|
||||
private bool IsGenerating(string entryKey) => _generating.Contains(entryKey);
|
||||
|
||||
private async Task GenerateOne(WaveformStatusDto row)
|
||||
{
|
||||
if (!await GenerateForRow(row))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Snackbar.Add($"Generated profile for '{row.TrackName}'.", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task GenerateAllMissing()
|
||||
{
|
||||
var missing = _waveformRows.Where(r => !r.HasProfile).ToList();
|
||||
var missing = statusResult.Value.Where(s => !s.HasProfile).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
@@ -285,14 +136,22 @@
|
||||
_bulkRunning = true;
|
||||
_bulkTotal = missing.Count;
|
||||
_bulkDone = 0;
|
||||
_grid?.SetBulkRunning(true);
|
||||
var failures = 0;
|
||||
|
||||
// Sequential by design: one request at a time so a large backfill does not flood the API
|
||||
// with concurrent WAV decodes.
|
||||
foreach (var row in missing)
|
||||
foreach (var status in missing)
|
||||
{
|
||||
if (!await GenerateForRow(row))
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_bulkDone++;
|
||||
@@ -300,6 +159,12 @@
|
||||
}
|
||||
|
||||
_bulkRunning = false;
|
||||
_grid?.SetBulkRunning(false);
|
||||
|
||||
if (_grid is not null)
|
||||
{
|
||||
await _grid.RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
var succeeded = missing.Count - failures;
|
||||
if (failures == 0)
|
||||
@@ -311,45 +176,4 @@
|
||||
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs generation for a single row, flipping its status on success. Returns false on failure
|
||||
/// (a snackbar is raised here for the per-row path; the bulk path aggregates a summary). Marks
|
||||
/// the row busy for the duration so its button shows a spinner and stays disabled.
|
||||
/// </summary>
|
||||
private async Task<bool> GenerateForRow(WaveformStatusDto row)
|
||||
{
|
||||
_generating.Add(row.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(row.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
row.HasProfile = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
if (!_bulkRunning)
|
||||
{
|
||||
Snackbar.Add($"Generate failed for '{row.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", row.EntryKey);
|
||||
if (!_bulkRunning)
|
||||
{
|
||||
Snackbar.Add($"Generate failed for '{row.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generating.Remove(row.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/tracks/new"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@@ -8,6 +9,7 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackNew> Logger
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
|
||||
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
@@ -202,7 +204,9 @@
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
_selectedFile.Name,
|
||||
createdByUserId);
|
||||
createdByUserId,
|
||||
releaseType: ReleaseType.Single,
|
||||
trackNumber: 1);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
@@ -227,6 +231,7 @@
|
||||
}
|
||||
|
||||
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
|
||||
VM.Invalidate();
|
||||
Navigation.NavigateTo("/tracks");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ builder.Services.AddMudServices();
|
||||
// DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer.
|
||||
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
||||
|
||||
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
|
||||
builder.Services.AddScoped<CmsTrackBrowserViewModel>();
|
||||
|
||||
// AuthBlocksWeb: server-side cascading auth state plus the JWT client services used by the
|
||||
// /account/login + /account/logout Razor pages that ship in the AuthBlocksWeb RCL.
|
||||
// The auth API lives on DeepDrftAPI, so pass its URL — not Manager's own Kestrel URL.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
|
||||
namespace DeepDrftManager.Services;
|
||||
|
||||
/// <summary>The three browse dimensions for the /tracks page.</summary>
|
||||
public enum BrowseMode
|
||||
{
|
||||
Tracks,
|
||||
Albums,
|
||||
Genres,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the /tracks browser's current mode plus the album- and genre-mode datasets. Scoped per
|
||||
/// circuit. Album and genre lists are fetched lazily on first switch into their mode and cached for
|
||||
/// the circuit's lifetime; Track mode owns its own paging inside <c>CmsTrackGrid</c> and needs no
|
||||
/// state here.
|
||||
/// </summary>
|
||||
public class CmsTrackBrowserViewModel
|
||||
{
|
||||
private readonly ICmsTrackService _trackService;
|
||||
|
||||
public CmsTrackBrowserViewModel(ICmsTrackService trackService)
|
||||
{
|
||||
_trackService = trackService;
|
||||
}
|
||||
|
||||
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks;
|
||||
|
||||
// Album mode.
|
||||
public IReadOnlyList<ReleaseDto> Albums { get; private set; } = Array.Empty<ReleaseDto>();
|
||||
public bool AlbumsLoading { get; private set; }
|
||||
|
||||
// Genre mode.
|
||||
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
|
||||
public bool GenresLoading { get; private set; }
|
||||
public string? ExpandedGenre { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Switch the active mode, lazily loading the album or genre dataset on first entry. Collapses
|
||||
/// any expanded genre row. The grid in Track mode owns its own data, so no fetch happens there.
|
||||
/// </summary>
|
||||
public async Task SwitchModeAsync(BrowseMode mode)
|
||||
{
|
||||
Mode = mode;
|
||||
ExpandedGenre = null; // collapse on mode switch
|
||||
|
||||
if (mode == BrowseMode.Albums && Albums.Count == 0 && !AlbumsLoading)
|
||||
{
|
||||
AlbumsLoading = true;
|
||||
var result = await _trackService.GetReleasesAsync();
|
||||
Albums = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<ReleaseDto>();
|
||||
AlbumsLoading = false;
|
||||
}
|
||||
else if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
|
||||
{
|
||||
GenresLoading = true;
|
||||
var result = await _trackService.GetGenreSummariesAsync();
|
||||
Genres = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<GenreSummaryDto>();
|
||||
GenresLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Toggle the expanded genre row. Selecting the already-expanded genre collapses it.</summary>
|
||||
public void SetExpandedGenre(string? genre)
|
||||
{
|
||||
ExpandedGenre = ExpandedGenre == genre ? null : genre;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop the cached album and genre datasets so the next <see cref="SwitchModeAsync"/> into
|
||||
/// either mode re-fetches from the API. Call after a track or release mutation (edit, delete)
|
||||
/// since both datasets are derived from the catalogue and go stale on any such change.
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
Albums = Array.Empty<ReleaseDto>();
|
||||
Genres = Array.Empty<GenreSummaryDto>();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
@@ -41,6 +42,8 @@ public class CmsTrackService : ICmsTrackService
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Rebuild the multipart container so the boundary is owned by HttpClient and the
|
||||
@@ -49,7 +52,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
var wavContent = new StreamContent(wavStream);
|
||||
wavContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType);
|
||||
multipart.Add(wavContent, "wav", fileName);
|
||||
multipart.Add(wavContent, "audioFile", fileName);
|
||||
multipart.Add(new StringContent(trackName), "trackName");
|
||||
multipart.Add(new StringContent(artist), "artist");
|
||||
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
|
||||
@@ -58,6 +61,8 @@ public class CmsTrackService : ICmsTrackService
|
||||
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
|
||||
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
|
||||
multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId");
|
||||
multipart.Add(new StringContent(releaseType.ToString()), "releaseType");
|
||||
multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
|
||||
@@ -147,8 +152,42 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> DeleteReleaseAsync(long releaseId, CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.DeleteAsync($"api/track/release/{releaseId}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for delete of release {ReleaseId}", releaseId);
|
||||
return Result.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return Result.CreateFailResult("Release not found.");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("Content API delete failed for release {ReleaseId}: {Status} {Body}", releaseId, (int)response.StatusCode, body);
|
||||
return Result.CreateFailResult("Failed to delete release.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
string? album = null, string? genre = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
@@ -157,6 +196,14 @@ public class CmsTrackService : ICmsTrackService
|
||||
{
|
||||
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
query += $"&album={Uri.EscapeDataString(album)}";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
{
|
||||
query += $"&genre={Uri.EscapeDataString(genre)}";
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
@@ -322,6 +369,8 @@ public class CmsTrackService : ICmsTrackService
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
int? trackNumber = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
@@ -333,6 +382,8 @@ public class CmsTrackService : ICmsTrackService
|
||||
genre,
|
||||
releaseDate,
|
||||
imagePath,
|
||||
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
|
||||
trackNumber,
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
@@ -440,4 +491,106 @@ public class CmsTrackService : ICmsTrackService
|
||||
return Result.CreateFailResult("Failed to generate waveform profile.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/track/albums", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for releases");
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API releases failed: {Status}", (int)response.StatusCode);
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Failed to load albums.");
|
||||
}
|
||||
|
||||
List<ReleaseDto>? releases;
|
||||
try
|
||||
{
|
||||
releases = await response.Content.ReadFromJsonAsync<List<ReleaseDto>>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize releases from Content API response");
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (releases is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null releases list");
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<List<ReleaseDto>>.CreatePassResult(releases);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/track/genres", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for genre summaries");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API genre summaries failed: {Status}", (int)response.StatusCode);
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Failed to load genres.");
|
||||
}
|
||||
|
||||
List<GenreSummaryDto>? genres;
|
||||
try
|
||||
{
|
||||
genres = await response.Content.ReadFromJsonAsync<List<GenreSummaryDto>>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize genre summaries from Content API response");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (genres is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null genre summaries list");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Re-use the paged endpoint: a single-item page carries the full TotalCount, so no
|
||||
// dedicated count endpoint is needed.
|
||||
var paged = await GetPagedAsync(page: 1, pageSize: 1, sortColumn: null, sortDescending: false, ct: ct);
|
||||
if (!paged.Success || paged.Value is null)
|
||||
{
|
||||
var error = paged.Messages.FirstOrDefault()?.Message ?? "Failed to load track count.";
|
||||
return ResultContainer<int>.CreateFailResult(error);
|
||||
}
|
||||
|
||||
return ResultContainer<int>.CreatePassResult(paged.Value.TotalCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
@@ -29,6 +30,8 @@ public interface ICmsTrackService
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -38,10 +41,19 @@ public interface ICmsTrackService
|
||||
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>.
|
||||
/// Soft-delete a release record via DELETE api/track/release/{id}. Use when a release
|
||||
/// has no live tracks and needs to be removed from the albums browser.
|
||||
/// </summary>
|
||||
Task<Result> DeleteReleaseAsync(long releaseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>. Optional
|
||||
/// <paramref name="album"/> and <paramref name="genre"/> filters narrow the result to a single
|
||||
/// release title or genre; null leaves the dimension unfiltered.
|
||||
/// </summary>
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
string? album = null, string? genre = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -69,6 +81,8 @@ public interface ICmsTrackService
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
int? trackNumber = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -82,4 +96,15 @@ public interface ICmsTrackService
|
||||
/// <c>POST api/track/{entryKey}/waveform</c>. Maps a 404 to a "Track audio not found." failure.
|
||||
/// </summary>
|
||||
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total track count by calling GET api/track/page with pageSize=1 and reading TotalCount.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace DeepDrftModels.DTOs;
|
||||
/// One distinct album with its track count and a representative cover image key. Backs the
|
||||
/// /albums browse grid.
|
||||
/// </summary>
|
||||
[Obsolete("Replaced by ReleaseDto. Use ITrackService.GetReleases().")]
|
||||
public class AlbumSummaryDto
|
||||
{
|
||||
public required string Album { get; set; }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Models;
|
||||
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
// Mirror of ReleaseEntity (Phase 8 §8.0). Inherits Id, CreatedAt, UpdatedAt from BaseModel
|
||||
// (Cerebellum.BlazorBlocks.Models). No `required` members — BlazorBlocks's Manager<> generic
|
||||
// constraint requires `new()`, which does not compose with required members (see TrackDto header).
|
||||
// TrackConverter assigns every field on the round-trip, so an empty default is never observable.
|
||||
public class ReleaseDto : BaseModel
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Genre { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
public long? CreatedByUserId { get; set; }
|
||||
|
||||
// Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the
|
||||
// service projects it from the joined Tracks collection so the /albums browse grid and the CMS
|
||||
// dashboard can show a per-album track count. Defaults to 0 when not populated.
|
||||
public int TrackCount { get; set; }
|
||||
}
|
||||
@@ -5,18 +5,17 @@ namespace DeepDrftModels.DTOs;
|
||||
// Inherits Id, CreatedAt, UpdatedAt from BaseModel (Cerebellum.BlazorBlocks.Models).
|
||||
// BlazorBlocks's Manager<> generic constraint requires `new()` on the model type, which
|
||||
// disqualifies `required` properties (the `new()` constraint and required members do not
|
||||
// compose). EntryKey/TrackName/Artist therefore drop `required` here — the TrackEntity
|
||||
// side remains required, and TrackConverter assigns every field on the round-trip so an
|
||||
// empty default is never observable in production code paths.
|
||||
// compose). EntryKey/TrackName therefore drop `required` here — the TrackEntity side remains
|
||||
// required, and TrackConverter assigns every field on the round-trip so an empty default is
|
||||
// never observable in production code paths.
|
||||
//
|
||||
// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields are read via Release?.X.
|
||||
public class TrackDto : BaseModel
|
||||
{
|
||||
public string EntryKey { get; set; } = string.Empty;
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Album { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public long? CreatedByUserId { get; set; }
|
||||
public string? OriginalFileName { get; set; }
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
public long? ReleaseId { get; set; }
|
||||
public ReleaseDto? Release { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Entities;
|
||||
|
||||
namespace DeepDrftModels.Entities;
|
||||
|
||||
// The release-cardinal half of the normalized track schema (Phase 8 §8.0). One ReleaseEntity is
|
||||
// shared by every track on the same album; track-cardinal data stays on TrackEntity, which points
|
||||
// back here via a nullable ReleaseId (singles and loose tracks have no release context).
|
||||
//
|
||||
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
|
||||
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses declare it
|
||||
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
||||
public class ReleaseEntity : BaseEntity, IEntity
|
||||
{
|
||||
public required string Title { get; set; }
|
||||
public required string Artist { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
public long? CreatedByUserId { get; set; }
|
||||
public ICollection<TrackEntity> Tracks { get; set; } = new List<TrackEntity>();
|
||||
}
|
||||
@@ -5,15 +5,16 @@ namespace DeepDrftModels.Entities;
|
||||
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
|
||||
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses
|
||||
// declare it explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
||||
//
|
||||
// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields (Artist, Album→Title, Genre,
|
||||
// ReleaseDate, ImagePath, ReleaseType, CreatedByUserId) live on ReleaseEntity, reached via the
|
||||
// nullable Release navigation; ReleaseId is null for singles and loose tracks.
|
||||
public class TrackEntity : BaseEntity, IEntity
|
||||
{
|
||||
public required string EntryKey { get; set; }
|
||||
public required string TrackName { get; set; }
|
||||
public required string Artist { get; set; }
|
||||
public string? Album { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public long? CreatedByUserId { get; set; }
|
||||
public string? OriginalFileName { get; set; }
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
public long? ReleaseId { get; set; }
|
||||
public ReleaseEntity? Release { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DeepDrftModels.Enums;
|
||||
|
||||
/// <summary>The commercial release format of a track's parent release.</summary>
|
||||
public enum ReleaseType
|
||||
{
|
||||
Single,
|
||||
EP,
|
||||
Album
|
||||
}
|
||||
@@ -61,18 +61,28 @@ Both are configured with JSON serializer settings (case-insensitive property mat
|
||||
### Implementation
|
||||
- `AudioPlayerService` (abstract base): Lifecycle. Stores current track, playback state, volume. `SelectTrack` throws `NotSupportedException` (buffered path is dead); derived classes override `SelectTrackStreaming`.
|
||||
- `StreamingAudioPlayerService` (production): Constructor takes `TrackMediaClient`, `AudioInteropService`, logger. `SelectTrackStreaming`:
|
||||
1. Calls `TrackMediaClient.GetAudioStreamAsync(trackId)`.
|
||||
2. `StreamingAudioPlayerService.StreamAudioAsync` reads chunks (16–64 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync` (JS interop call).
|
||||
3. TypeScript `StreamDecoder` parses WAV header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
|
||||
1. Calls `TrackMediaClient.GetAudioStreamAsync(trackId)`, which returns a response object including `ContentType` (e.g., `audio/wav`, `audio/mpeg`, `audio/flac`).
|
||||
2. `StreamingAudioPlayerService.StreamAudioAsync` reads chunks (16–64 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk)` (JS interop call with format hint).
|
||||
3. TypeScript `StreamDecoder` is format-agnostic; delegates format-specific header parsing and chunked decoding to the appropriate `IFormatDecoder` implementation (e.g., `WavFormatDecoder` for WAV, TBD MP3/FLAC decoders for other formats). Decoder parses header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
|
||||
4. `PlaybackScheduler` schedules buffers on Web Audio `AudioContext`.
|
||||
5. Playback starts as soon as a configurable min buffer count is queued.
|
||||
6. **Seek beyond buffer**: if seek target is past the decoded range, `Seek(position)` calls `TrackMediaClient.GetAudioStreamAsync(trackId, byteOffset)` with a file-absolute byte offset. Client sends `Range: bytes={offset}-`; server responds 206 with raw PCM; decoder retains the parsed WAV header and feeds the continuation directly into the decode pipeline.
|
||||
6. **Seek beyond buffer**: if seek target is past the decoded range, `Seek(position)` calls `TrackMediaClient.GetAudioStreamAsync(trackId, byteOffset)` with a file-absolute byte offset. Client sends `Range: bytes={offset}-`; server responds 206 with raw bytes (same format as original file); decoder retains the parsed header and feeds the continuation directly into the decode pipeline.
|
||||
|
||||
### Interop bridge
|
||||
- `AudioInteropService.CreatePlayerAsync` polls `DeepDrftAudio.isReady()` before proceeding; `index.ts` sets `ready = true` after attaching the API to `window`. This guards against slow WASM boot / cache misses.
|
||||
- `AudioInteropService.ProcessStreamingChunkAsync(chunk)` calls JS `window.DeepDrftAudio.processStreamingChunk(chunk)` and awaits the Promise.
|
||||
- `AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk)` calls JS `window.DeepDrftAudio.processStreamingChunk(contentType, chunk)` and awaits the Promise. The `contentType` parameter is passed through to the format-decoder factory.
|
||||
- `AudioInteropService` also manages callback registrations for progress (fired by `PlaybackScheduler`), end-of-playback (fired by `PlaybackScheduler`), and spectrum data (fired by `SpectrumAnalyzer`). Each callback is a `DotNetObjectReference` to a delegate.
|
||||
|
||||
### Format decoders (TypeScript)
|
||||
New modules in `DeepDrftPublic/Interop/audio/`:
|
||||
|
||||
- `IFormatDecoder.ts`: Interface. Defines contract for format-specific decoders: `parseHeader(chunk, offset)` → header metadata; `decodeChunk(chunk, offset)` → `AudioBuffer`; `getAlignedSegmentSize(chunk, offset, rawData?)` → frame-aligned segment boundary (optional `rawData` parameter for format-specific frame-boundary scanning).
|
||||
- `WavFormatDecoder.ts`: Concrete WAV implementation (active). Parses RIFF/WAVE structure, fmt and data chunks. All WAV-specific byte-parsing logic lives here. Exported as the default WAV decoder.
|
||||
- `Mp3FormatDecoder.ts`: Concrete MP3 implementation (implemented, not yet wired). Implements `IFormatDecoder` for MP3: ID3v2 skip, MPEG Layer III frame-sync + header decode (MPEG1/2/2.5), Xing/Info/VBRI VBR-header detection (frame count + 100-entry TOC for seek), CBR frame-aligned segment sizing, VBR TOC-interpolation seek (`calculateByteOffset`), zero-copy `wrapSegment` (raw MP3 frames are self-contained). CBR sub-frame tail guard prevents over-read.
|
||||
- `FlacFormatDecoder.ts`: Concrete FLAC implementation (implemented, not yet wired). Implements `IFormatDecoder` for FLAC: scans all metadata blocks (STREAMINFO mandatory, SEEKTABLE optional), extracts 20-bit sample rate / 3-bit channels / 5-bit bitsPerSample / 36-bit total-samples from bit-packed STREAMINFO, builds 38-byte synthetic STREAMINFO block for per-segment wrapping, binary-search SEEKTABLE for seek. `wrapSegment` prepends `fLaC + STREAMINFO` to each audio segment so `decodeAudioData` sees a valid FLAC stream. `getAlignedSegmentSize` scans backward through peek bytes for the `0xFF/(0xF8|0xF9)` FLAC frame sync so each segment ends on a real frame boundary.
|
||||
|
||||
`StreamDecoder.ts` remains the orchestrator — it accepts the first chunk, selects the right format decoder via factory (based on `contentType`), peeks candidate bytes before calling `getAlignedSegmentSize` (non-destructive read), passes them as `rawData`, and uses zero-copy `subarray` for the actual segment. It delegates all format-specific work to the decoder and chains subsequent chunks through the same decoder instance. `Mp3FormatDecoder` and `FlacFormatDecoder` are implemented modules but not yet wired into `AudioPlayer.createFormatDecoder` factory (Wave 3 pending).
|
||||
|
||||
### Component integration
|
||||
- `AudioPlayerProvider.razor` is the cascading host. It injects `IStreamingPlayerService` (resolved to `StreamingAudioPlayerService` in DI), stores it in a cascade with `IsFixed="true"`, and keeps it alive across navigation.
|
||||
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Minimize-state mutations (`Expand`, `ToggleMinimized`, `Close`) all route through a private `SetMinimized(bool value)` mutator, which guards no-ops, fires the `OnMinimized` callback, and calls `StateHasChanged()`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
|
||||
|
||||
@@ -89,22 +89,22 @@ public class TrackClient
|
||||
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
|
||||
public async Task<ApiResult<List<ReleaseDto>>> GetAlbums()
|
||||
{
|
||||
var response = await _http.GetAsync("api/track/albums");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<List<AlbumSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
return ApiResult<List<ReleaseDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var albums = JsonSerializer.Deserialize<List<AlbumSummaryDto>>(json, new JsonSerializerOptions
|
||||
var releases = JsonSerializer.Deserialize<List<ReleaseDto>>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return albums is not null
|
||||
? ApiResult<List<AlbumSummaryDto>>.CreatePassResult(albums)
|
||||
: ApiResult<List<AlbumSummaryDto>>.CreateFailResult("Failed to deserialize response");
|
||||
return releases is not null
|
||||
? ApiResult<List<ReleaseDto>>.CreatePassResult(releases)
|
||||
: ApiResult<List<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
|
||||
|
||||
@@ -11,12 +11,20 @@ public class TrackMediaResponse : IDisposable
|
||||
{
|
||||
public Stream Stream { get; }
|
||||
public long ContentLength { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The response media type (e.g. "audio/wav", "audio/mpeg"). Drives format-decoder
|
||||
/// selection on the JS side. Falls back to "audio/wav" when the server omits the header.
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
private readonly HttpResponseMessage _response;
|
||||
|
||||
public TrackMediaResponse(Stream stream, long contentLength, HttpResponseMessage response)
|
||||
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
|
||||
{
|
||||
Stream = stream;
|
||||
ContentLength = contentLength;
|
||||
ContentType = contentType;
|
||||
_response = response;
|
||||
}
|
||||
|
||||
@@ -61,11 +69,14 @@ public class TrackMediaClient
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength ?? 0;
|
||||
// Default to WAV when the server omits the header — the only format shipping
|
||||
// today — so the JS factory always receives a usable media type.
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
// TrackMediaResponse takes ownership of both stream and response;
|
||||
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, response));
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@@ -13,26 +13,26 @@
|
||||
</a>
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-sep"> - </MudText>
|
||||
<MudText Typo="Typo.caption" Class="track-meta-artist text-truncate">
|
||||
@Track.Artist
|
||||
@Track.Release?.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
|
||||
<div class="track-meta-accents">
|
||||
@if (!string.IsNullOrEmpty(Track.Genre))
|
||||
@if (!string.IsNullOrEmpty(Track.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@Track.Genre
|
||||
@Track.Release.Genre
|
||||
</MudChip>
|
||||
}
|
||||
|
||||
@if (Track.ReleaseDate.HasValue)
|
||||
@if (Track.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="track-meta-year">
|
||||
@Track.ReleaseDate.Value.Year
|
||||
@Track.Release.ReleaseDate.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="np-title">@(Player?.CurrentTrack?.TrackName ?? "Nothing playing")</div>
|
||||
<div class="np-sub">
|
||||
@(Player?.CurrentTrack != null
|
||||
? $"{Player.CurrentTrack.Artist} · {Player.CurrentTrack.Album ?? "Single"}"
|
||||
? $"{Player.CurrentTrack.Release?.Artist} · {Player.CurrentTrack.Release?.Title ?? "Single"}"
|
||||
: "Select a track to begin")
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@{
|
||||
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
|
||||
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
|
||||
var hasArt = !string.IsNullOrEmpty(TrackModel?.ImagePath);
|
||||
var hasArt = !string.IsNullOrEmpty(TrackModel?.Release?.ImagePath);
|
||||
}
|
||||
|
||||
@if (ViewMode == GalleryViewMode.Grid)
|
||||
@@ -13,9 +13,9 @@
|
||||
@if (hasLink)
|
||||
{
|
||||
<a href="@trackHref" class="deepdrft-track-card-link">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
|
||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -24,9 +24,9 @@
|
||||
}
|
||||
</a>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
|
||||
else if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
|
||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-artist text-truncate mb-2">
|
||||
@TrackModel?.Artist
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
</a>
|
||||
@@ -62,38 +62,38 @@
|
||||
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-artist text-truncate mb-2">
|
||||
@TrackModel?.Artist
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="deepdrft-track-info-middle">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Album))
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Title))
|
||||
{
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel.Album
|
||||
@TrackModel.Release!.Title
|
||||
</MudText>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Genre
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="deepdrft-track-info-bottom">
|
||||
@if (TrackModel?.ReleaseDate.HasValue == true)
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-meta">
|
||||
@TrackModel.ReleaseDate.Value.Year
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
@@ -127,10 +127,10 @@ else
|
||||
{
|
||||
<a href="@trackHref" class="deepdrft-track-row-link">
|
||||
@* art thumb *@
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -141,7 +141,7 @@ else
|
||||
@* text block *@
|
||||
<div class="deepdrft-track-row-text">
|
||||
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
||||
@TrackModel?.Artist
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel?.TrackName
|
||||
@@ -150,20 +150,20 @@ else
|
||||
|
||||
@* right metadata *@
|
||||
<div class="deepdrft-track-row-meta">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Genre
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
@if (TrackModel?.ReleaseDate.HasValue == true)
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
||||
@TrackModel.ReleaseDate.Value.Year
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
@@ -172,10 +172,10 @@ else
|
||||
else
|
||||
{
|
||||
@* same structure without anchor *@
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -184,27 +184,27 @@ else
|
||||
}
|
||||
<div class="deepdrft-track-row-text">
|
||||
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
||||
@TrackModel?.Artist
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
</div>
|
||||
<div class="deepdrft-track-row-meta">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Genre
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
@if (TrackModel?.ReleaseDate.HasValue == true)
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
||||
@TrackModel.ReleaseDate.Value.Year
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
<div class="album-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@onclick="@(() => OpenAlbum(album.Album))">
|
||||
@if (!string.IsNullOrEmpty(album.CoverImageKey))
|
||||
@onclick="@(() => OpenAlbum(album.Title))">
|
||||
@if (!string.IsNullOrEmpty(album.ImagePath))
|
||||
{
|
||||
<div class="album-card-cover"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(album.CoverImageKey)');">
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(album.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div class="album-card-body">
|
||||
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
|
||||
@album.Album
|
||||
@album.Title
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="album-card-count">
|
||||
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
|
||||
|
||||
@@ -10,7 +10,7 @@ public partial class AlbumsView : ComponentBase
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private List<AlbumSummaryDto> _albums = [];
|
||||
private List<ReleaseDto> _albums = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
</div>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
<ParallaxImage Image1="img/dd-duo-hero-bw.jpeg"
|
||||
Image2="img/dd-duo-hero.jpeg"
|
||||
Alt1="Deep DRFT Electronic Music Duo"
|
||||
FullWidth
|
||||
InvertDirection
|
||||
NaturalWidth="2048"
|
||||
NaturalHeight="1365"
|
||||
WindowHeightFraction="0.45"
|
||||
ImageHeight="auto"
|
||||
ImageWidth="100%"
|
||||
ParallaxSpeed="0.35"
|
||||
Class="my-8"/>
|
||||
</section>
|
||||
|
||||
@* Divider *@
|
||||
@@ -28,47 +40,53 @@
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
|
||||
@* Sound section *@
|
||||
@* Medium section *@
|
||||
<section class="section">
|
||||
<div class="section-header-grid">
|
||||
<MudGrid Style="margin-bottom: 5rem;">
|
||||
<MudItem xs="12" md="4">
|
||||
<div class="section-label">Genres & Moods</div>
|
||||
<h2 class="section-title">Every<br /><em>Frequency</em><br />Explored</h2>
|
||||
<div class="section-label">Format & Medium</div>
|
||||
<h2 class="section-title">Music through<br /><em>Every</em><br />Medium</h2>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="8">
|
||||
<p class="section-body">
|
||||
From the hypnotic pulse of deep house to the fractal complexity of IDM, Deep DRFT refuses to be categorized. We treat genre as a palette — not a cage. Each session begins as something and ends as something else entirely.
|
||||
The same hands, three different rooms. A studio cut is built; a live set is risked; a DJ mix is woven. We release in every form the music asks for — each one a different relationship between the moment and the record of it.
|
||||
</p>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</div>
|
||||
|
||||
<div class="genre-grid">
|
||||
@* TODO Phase 2.2: wire each genre to /genres/{slug} *@
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">House</div>
|
||||
<div class="genre-count">Foundation</div>
|
||||
<div class="medium-grid">
|
||||
@* TODO Phase 3.x: wire each card to its format-filtered browse route once /tracks?format= exists *@
|
||||
<div class="medium-card">
|
||||
<div class="medium-image" style="background-image: url('img/dd-studio.jpg');">
|
||||
<div class="medium-scrim"></div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">Techno</div>
|
||||
<div class="genre-count">Architecture</div>
|
||||
<div class="medium-body">
|
||||
<div class="medium-type">Studio</div>
|
||||
<div class="medium-name">Studio Releases</div>
|
||||
<div class="medium-desc">Composed, layered, and finished — tracks built to be returned to.</div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">Trance</div>
|
||||
<div class="genre-count">Ascension</div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">IDM</div>
|
||||
<div class="genre-count">Intelligence</div>
|
||||
<div class="medium-card">
|
||||
<div class="medium-image" style="background-image: url('img/dd-live.jpeg');">
|
||||
<div class="medium-scrim"></div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">Progressive</div>
|
||||
<div class="genre-count">Journey</div>
|
||||
<div class="medium-body">
|
||||
<div class="medium-type">Live</div>
|
||||
<div class="medium-name">Live Releases</div>
|
||||
<div class="medium-desc">Performances caught in the moment, unrepeatable and unedited.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="medium-card">
|
||||
<div class="medium-image" style="background-image: url('img/dd-dj.jpeg');">
|
||||
<div class="medium-scrim"></div>
|
||||
</div>
|
||||
<div class="medium-body">
|
||||
<div class="medium-type">Mix</div>
|
||||
<div class="medium-name">DJ Mix Releases</div>
|
||||
<div class="medium-desc">Uninterrupted sets — one track bleeding into the next, start to finish.</div>
|
||||
</div>
|
||||
<div class="genre-card">
|
||||
<div class="genre-name">Ambient</div>
|
||||
<div class="genre-count">Atmosphere</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -127,31 +145,14 @@
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="split-right">
|
||||
<div class="connect-label">Stay Connected</div>
|
||||
<h2 class="connect-title">Never Miss<br />a <em>Session</em></h2>
|
||||
<div class="connect-options">
|
||||
@* TODO: wire to subscription system when newsletter/alerts backend exists *@
|
||||
<div class="connect-option">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" /><polyline points="22,6 12,13 2,6" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="option-text-label">Newsletter</div>
|
||||
<div class="option-text-sub">New releases & studio dispatches</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connect-option">
|
||||
<div class="option-icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.73 21a2 2 0 0 1-3.46 0" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="option-text-label">Live Alerts</div>
|
||||
<div class="option-text-sub">Know the moment we go live</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@* TODO: subscription form lives behind this CTA *@
|
||||
<button class="btn-primary connect-cta" type="button">Subscribe Free</button>
|
||||
<ParallaxImage Image1="img/kp-shoulder-bw.jpeg"
|
||||
InvertDirection
|
||||
NaturalWidth="1365"
|
||||
NaturalHeight="2048"
|
||||
WindowHeightFraction="0.5"
|
||||
ImageHeight="auto"
|
||||
ImageWidth="100%"
|
||||
ParallaxSpeed="0.6" />
|
||||
</div>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@@ -103,36 +103,36 @@
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
/* Genre grid */
|
||||
.genre-grid {
|
||||
/* ── MEDIUM GRID (Music through Every Medium) ── */
|
||||
.medium-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--deepdrft-border);
|
||||
border: 1px solid var(--deepdrft-border);
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.genre-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
@media (max-width: 959px) {
|
||||
.medium-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.medium-card:last-child { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.genre-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.medium-grid { grid-template-columns: 1fr; }
|
||||
.medium-card:last-child { grid-column: auto; }
|
||||
}
|
||||
|
||||
.genre-card {
|
||||
.medium-card {
|
||||
background: var(--deepdrft-white);
|
||||
padding: 2rem 1.5rem;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.genre-card::after {
|
||||
.medium-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -143,25 +143,63 @@
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.3s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.genre-card:hover::after { transform: scaleX(1); }
|
||||
.genre-card:hover { background: #f3f6f4; }
|
||||
.medium-card:hover::after { transform: scaleX(1); }
|
||||
|
||||
.genre-name {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
margin-bottom: 0.5rem;
|
||||
.medium-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.genre-count {
|
||||
.medium-card:hover .medium-image { transform: scale(1.05); }
|
||||
|
||||
.medium-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(17, 35, 56, 0.0) 40%,
|
||||
rgba(17, 35, 56, 0.35) 100%);
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.medium-card:hover .medium-scrim { opacity: 1; }
|
||||
|
||||
.medium-body {
|
||||
padding: 2rem 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.medium-type {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--deepdrft-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.medium-name {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.medium-desc {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.65;
|
||||
color: var(--deepdrft-navy);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── DARK FEATURE SECTION ── */
|
||||
@@ -283,7 +321,6 @@
|
||||
}
|
||||
|
||||
.split-right {
|
||||
padding: 6rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
@@ -43,7 +43,9 @@ else if (ViewModel.Track is not null)
|
||||
var isThisTrackPlaying = PlayerService.CurrentTrack?.Id == track.Id
|
||||
&& PlayerService.IsPlaying
|
||||
&& !PlayerService.IsPaused;
|
||||
var hasMeta = track.Album is not null || track.Genre is not null || track.ReleaseDate is not null;
|
||||
var release = track.Release;
|
||||
var hasMeta = release is not null
|
||||
&& (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null);
|
||||
|
||||
<div class="deepdrft-track-detail-container">
|
||||
|
||||
@@ -54,7 +56,7 @@ else if (ViewModel.Track is not null)
|
||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h3">@track.TrackName</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@track.Artist</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@release?.Artist</MudText>
|
||||
</div>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
@@ -64,10 +66,10 @@ else if (ViewModel.Track is not null)
|
||||
</MudStack>
|
||||
|
||||
<div class="deepdrft-track-detail-cover">
|
||||
@if (!string.IsNullOrEmpty(track.ImagePath))
|
||||
@if (!string.IsNullOrEmpty(release?.ImagePath))
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
|
||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(track.ImagePath)}');")" />
|
||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -82,31 +84,31 @@ else if (ViewModel.Track is not null)
|
||||
<MudDivider />
|
||||
|
||||
<div class="deepdrft-track-detail-meta">
|
||||
@if (track.Album is not null)
|
||||
@if (release?.Title is not null)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.overline">Album</MudText>
|
||||
<MudText Typo="Typo.body1">@track.Album</MudText>
|
||||
<MudText Typo="Typo.body1">@release.Title</MudText>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (track.Genre is not null)
|
||||
@if (release?.Genre is not null)
|
||||
{
|
||||
<div>
|
||||
<MudChip T="string"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@track.Genre
|
||||
@release.Genre
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (track.ReleaseDate is not null)
|
||||
@if (release?.ReleaseDate is not null)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.overline">Released</MudText>
|
||||
<MudText Typo="Typo.body1">@track.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
|
||||
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -65,9 +65,9 @@ public class AudioInteropService : IAsyncDisposable
|
||||
}
|
||||
|
||||
// Streaming methods
|
||||
public async Task<AudioOperationResult> InitializeStreaming(string playerId, long totalStreamLength)
|
||||
public async Task<AudioOperationResult> InitializeStreaming(string playerId, long totalStreamLength, string contentType)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength);
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType);
|
||||
}
|
||||
|
||||
public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk)
|
||||
@@ -128,18 +128,6 @@ public class AudioInteropService : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> CalculateByteOffset(string playerId, double positionSeconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (long)await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.calculateByteOffset", playerId, positionSeconds);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AudioOperationResult> ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition);
|
||||
|
||||
@@ -21,8 +21,8 @@ public interface ITrackDataService
|
||||
string? album = null,
|
||||
string? genre = null);
|
||||
|
||||
/// <summary>Distinct non-null albums with track counts and a representative cover key.</summary>
|
||||
Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums();
|
||||
/// <summary>All releases with track counts, title-ascending.</summary>
|
||||
Task<ApiResult<List<ReleaseDto>>> GetAlbums();
|
||||
|
||||
/// <summary>Distinct non-null genres with track counts.</summary>
|
||||
Task<ApiResult<List<GenreSummaryDto>>> GetGenres();
|
||||
|
||||
@@ -143,8 +143,9 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
|
||||
using var audio = mediaResult.Value;
|
||||
|
||||
// Initialize streaming mode with content length
|
||||
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength);
|
||||
// Initialize streaming mode with content length and media type (drives
|
||||
// JS format-decoder selection).
|
||||
var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength, audio.ContentType);
|
||||
if (!streamingResult.Success)
|
||||
{
|
||||
var technicalError = $"Failed to initialize streaming: {streamingResult.Error}";
|
||||
|
||||
@@ -29,7 +29,7 @@ public class TrackClientDataService : ITrackDataService
|
||||
string? genre = null)
|
||||
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
|
||||
|
||||
public Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
|
||||
public Task<ApiResult<List<ReleaseDto>>> GetAlbums()
|
||||
=> _trackClient.GetAlbums();
|
||||
|
||||
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["DeepDrftPublic.styles.css"]"/>
|
||||
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/styles/deepdrft-tokens.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/css/parallax.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["styles/deepdrft-styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" />
|
||||
@@ -19,6 +20,7 @@
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveAuto" />
|
||||
<script src="@Assets["_content/DeepDrftShared.Client/scripts/parallax-init.js"]"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
|
||||
<script type="module">
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
import { AudioContextManager } from './AudioContextManager.js';
|
||||
import { StreamDecoder } from './StreamDecoder.js';
|
||||
import { PlaybackScheduler } from './PlaybackScheduler.js';
|
||||
import { IFormatDecoder } from './IFormatDecoder.js';
|
||||
import { WavFormatDecoder } from './WavFormatDecoder.js';
|
||||
import { Mp3FormatDecoder } from './Mp3FormatDecoder.js';
|
||||
import { FlacFormatDecoder } from './FlacFormatDecoder.js';
|
||||
|
||||
export interface AudioResult {
|
||||
success: boolean;
|
||||
@@ -89,7 +93,7 @@ export class AudioPlayer {
|
||||
|
||||
// ==================== Streaming ====================
|
||||
|
||||
initializeStreaming(totalStreamLength: number): AudioResult {
|
||||
initializeStreaming(totalStreamLength: number, contentType: string): AudioResult {
|
||||
try {
|
||||
// Full cleanup before starting new stream
|
||||
this.stopProgressTracking();
|
||||
@@ -97,15 +101,29 @@ export class AudioPlayer {
|
||||
this.streamDecoder.reset();
|
||||
this.resetState();
|
||||
|
||||
// Initialize new stream
|
||||
// Initialize new stream with the format decoder selected from Content-Type.
|
||||
this.isStreamingMode = true;
|
||||
this.streamDecoder.initialize(totalStreamLength);
|
||||
const formatDecoder = AudioPlayer.createFormatDecoder(contentType);
|
||||
this.streamDecoder.initialize(totalStreamLength, formatDecoder);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a format decoder from the response Content-Type.
|
||||
*/
|
||||
private static createFormatDecoder(contentType: string): IFormatDecoder {
|
||||
if (contentType.includes('audio/mpeg') || contentType.includes('audio/mp3')) {
|
||||
return new Mp3FormatDecoder();
|
||||
}
|
||||
if (contentType.includes('audio/flac') || contentType.includes('audio/x-flac')) {
|
||||
return new FlacFormatDecoder();
|
||||
}
|
||||
return new WavFormatDecoder(); // default (audio/wav, unknown)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal to the decoder that the C# streaming loop has finished sending bytes.
|
||||
* This sets streamComplete=true and flushes any remaining decoded tail segments.
|
||||
@@ -321,22 +339,16 @@ export class AudioPlayer {
|
||||
*/
|
||||
private seekBeyondBuffer(position: number): AudioResult {
|
||||
try {
|
||||
const audioOffset = this.streamDecoder.calculateByteOffset(position);
|
||||
// 0 is a valid offset (seek to start of audio data). Only a negative result
|
||||
// indicates calculation failure — typically a missing/unparsed WAV header.
|
||||
if (audioOffset < 0) {
|
||||
// The header must be parsed for byte-offset math; without it we cannot
|
||||
// build a valid Range request.
|
||||
if (!this.streamDecoder.getFormatInfo()) {
|
||||
return { success: false, error: 'Cannot calculate byte offset' };
|
||||
}
|
||||
|
||||
// The Range request is file-absolute: byte position from the start of the
|
||||
// file on disk, header included. calculateByteOffset returns an audio-data-
|
||||
// relative offset, so add headerSize to land on the right byte. (The old
|
||||
// ?offset= contract was audio-relative; the server added the header itself.)
|
||||
const header = this.streamDecoder.getWavHeader();
|
||||
if (!header) {
|
||||
return { success: false, error: 'Cannot calculate byte offset' };
|
||||
}
|
||||
const fileOffset = header.headerSize + audioOffset;
|
||||
// calculateByteOffset returns a file-absolute offset (byte position from the
|
||||
// start of the file on disk, header included) — exactly what the Range request
|
||||
// needs. The format decoder owns the header-offset addition and frame alignment.
|
||||
const fileOffset = this.streamDecoder.calculateByteOffset(position);
|
||||
|
||||
// Signal that C# needs to request a new stream from this file-absolute offset
|
||||
return {
|
||||
@@ -360,6 +372,7 @@ export class AudioPlayer {
|
||||
* Calculate byte offset for a time position (for C# layer)
|
||||
*/
|
||||
calculateByteOffset(positionSeconds: number): number {
|
||||
if (!this.streamDecoder.getFormatInfo()) return 0;
|
||||
return this.streamDecoder.calculateByteOffset(positionSeconds);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* FlacFormatDecoder - FLAC implementation of IFormatDecoder.
|
||||
*
|
||||
* FLAC differs from WAV (raw PCM) and MP3 (self-contained frames): no audio frame
|
||||
* is decodable without the STREAMINFO metadata block that opens the stream. So this
|
||||
* decoder captures STREAMINFO during header parsing and wrapSegment prepends a minimal
|
||||
* valid FLAC header ("fLaC" + STREAMINFO) to every raw audio segment, making each segment
|
||||
* independently decodable by the browser's decodeAudioData.
|
||||
*
|
||||
* Seeking uses the optional SEEKTABLE metadata block when present; absent a table, seek
|
||||
* degrades to the start of audio (restart), which is correct behavior with no seek points.
|
||||
*/
|
||||
|
||||
import { FlacSeekData, FormatInfo, IFormatDecoder } from './IFormatDecoder.js';
|
||||
|
||||
const FLAC_MAGIC = [0x66, 0x4c, 0x61, 0x43]; // "fLaC"
|
||||
const STREAMINFO_DATA_LEN = 34;
|
||||
const SEEK_POINT_SIZE = 18;
|
||||
const PLACEHOLDER_HI = 0xffffffff; // sample_number placeholder = 0xFFFFFFFFFFFFFFFF
|
||||
const TWO_POW_32 = 4294967296;
|
||||
|
||||
export class FlacFormatDecoder implements IFormatDecoder {
|
||||
tryParseHeader(chunks: Uint8Array[], totalSize: number): FormatInfo | null {
|
||||
const buf = concat(chunks, totalSize);
|
||||
|
||||
// Need at least the magic to decide anything.
|
||||
if (buf.length < 4) return null;
|
||||
if (buf[0] !== FLAC_MAGIC[0] || buf[1] !== FLAC_MAGIC[1] ||
|
||||
buf[2] !== FLAC_MAGIC[2] || buf[3] !== FLAC_MAGIC[3]) {
|
||||
return null; // silently; StreamDecoder will error when MAX_HEADER_SEARCH_BYTES is exhausted
|
||||
}
|
||||
|
||||
let sampleRate = 0;
|
||||
let channels = 0;
|
||||
let bitsPerSample = 0;
|
||||
let totalSamples = 0;
|
||||
let streamInfoBytes: Uint8Array | null = null;
|
||||
let seekPoints: FlacSeekData['points'] = [];
|
||||
|
||||
// Scan metadata blocks starting after the 4-byte magic.
|
||||
let offset = 4;
|
||||
while (true) {
|
||||
// Each block opens with a 4-byte header.
|
||||
if (offset + 4 > buf.length) return null;
|
||||
|
||||
const isLast = (buf[offset] & 0x80) !== 0;
|
||||
const blockType = buf[offset] & 0x7f;
|
||||
const dataLen = (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3];
|
||||
const dataStart = offset + 4;
|
||||
|
||||
// Need the full block data before we can advance.
|
||||
if (dataStart + dataLen > buf.length) return null;
|
||||
|
||||
if (blockType === 0) {
|
||||
// STREAMINFO (mandatory first block). data offsets are relative to dataStart.
|
||||
const d = dataStart;
|
||||
sampleRate = (buf[d + 10] << 12) | (buf[d + 11] << 4) | (buf[d + 12] >> 4);
|
||||
channels = ((buf[d + 12] >> 1) & 0x07) + 1;
|
||||
bitsPerSample = (((buf[d + 12] & 0x01) << 4) | (buf[d + 13] >> 4)) + 1;
|
||||
totalSamples = ((buf[d + 13] & 0x0f) * TWO_POW_32) + readUint32BE(buf, d + 14);
|
||||
|
||||
// Build the 38-byte synthetic block: header (is_last=1, type=0, len=34) + 34 data bytes.
|
||||
streamInfoBytes = new Uint8Array(4 + STREAMINFO_DATA_LEN);
|
||||
streamInfoBytes[0] = 0x80; // is_last=1, block_type=0
|
||||
streamInfoBytes[1] = 0x00;
|
||||
streamInfoBytes[2] = 0x00;
|
||||
streamInfoBytes[3] = STREAMINFO_DATA_LEN; // 0x22
|
||||
streamInfoBytes.set(buf.subarray(d, d + STREAMINFO_DATA_LEN), 4);
|
||||
} else if (blockType === 3) {
|
||||
// SEEKTABLE (optional). Each point is 18 bytes.
|
||||
const count = Math.floor(dataLen / SEEK_POINT_SIZE);
|
||||
const points: FlacSeekData['points'] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = dataStart + i * SEEK_POINT_SIZE;
|
||||
const sampleHi = readUint32BE(buf, p);
|
||||
const sampleLo = readUint32BE(buf, p + 4);
|
||||
// Placeholder points (sample_number = all 1s) carry no offset — skip.
|
||||
if (sampleHi === PLACEHOLDER_HI && sampleLo === PLACEHOLDER_HI) continue;
|
||||
|
||||
// sample_number: imprecise beyond 2^53 (~8h at 44100Hz); acceptable for seek nearest.
|
||||
const sampleNumber = sampleHi * TWO_POW_32 + sampleLo;
|
||||
// stream_offset: bytes from start of audio frames; safe to 2^53 for sub-petabyte files.
|
||||
const offsetHi = readUint32BE(buf, p + 8);
|
||||
const offsetLo = readUint32BE(buf, p + 12);
|
||||
const streamOffset = offsetHi * TWO_POW_32 + offsetLo;
|
||||
points.push({ sampleNumber, streamOffset });
|
||||
}
|
||||
seekPoints = points;
|
||||
}
|
||||
|
||||
offset = dataStart + dataLen;
|
||||
if (isLast) break;
|
||||
}
|
||||
|
||||
if (!streamInfoBytes) {
|
||||
console.warn('FlacFormatDecoder: no STREAMINFO block found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioDataOffset = offset; // 4 (magic) + sum of all block header+data sizes
|
||||
const totalDuration = sampleRate > 0 && totalSamples > 0
|
||||
? totalSamples / sampleRate : null;
|
||||
|
||||
return {
|
||||
sampleRate,
|
||||
channels,
|
||||
bitsPerSample,
|
||||
byteRate: 0, // FLAC is VBR; seeking uses SEEKTABLE or degrades gracefully.
|
||||
blockAlign: 0, // Variable-size FLAC frames; no fixed alignment.
|
||||
totalDuration,
|
||||
audioDataOffset,
|
||||
seekData: {
|
||||
kind: 'flac-seektable',
|
||||
points: seekPoints,
|
||||
streamInfoBytes,
|
||||
metadataBlocksSize: audioDataOffset - 4 // metadata bytes, excluding fLaC magic
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getAlignedSegmentSize(
|
||||
info: FormatInfo,
|
||||
availableBytes: number,
|
||||
requestedSize: number,
|
||||
streamComplete: boolean,
|
||||
rawData?: Uint8Array
|
||||
): number {
|
||||
if (availableBytes === 0) return 0;
|
||||
const candidate = Math.min(requestedSize, availableBytes);
|
||||
|
||||
if (!rawData || rawData.length === 0) {
|
||||
// No scan data — conservative threshold to avoid tiny unusable segments
|
||||
if (!streamComplete && availableBytes < 16384) return 0;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// Scan backward from the candidate boundary to find the last FLAC frame sync code.
|
||||
const boundary = FlacFormatDecoder.findLastFlacFrame(rawData, candidate);
|
||||
if (boundary <= 0) {
|
||||
if (streamComplete) return candidate; // flush remaining bytes (stream done)
|
||||
return 0; // wait for more data
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan backward from `maxBytes` in `rawData` to find the start of the last valid FLAC
|
||||
* audio frame. FLAC frame sync: 0xFF followed by a byte where top 7 bits are 0xF8
|
||||
* (i.e. (byte & 0xFE) === 0xF8 — covers both blocking-strategy variants 0xF8 and 0xF9).
|
||||
* Returns the byte offset of that sync, or 0 if none is found (causes caller to wait).
|
||||
*/
|
||||
private static findLastFlacFrame(rawData: Uint8Array, maxBytes: number): number {
|
||||
const limit = Math.min(maxBytes, rawData.length);
|
||||
// Need at least 2 bytes to verify sync pair; skip the very last byte.
|
||||
for (let i = limit - 2; i > 0; i--) {
|
||||
if (rawData[i] === 0xFF && (rawData[i + 1] & 0xFE) === 0xF8) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array {
|
||||
const flacData = info.seekData as FlacSeekData | null | undefined;
|
||||
const streamInfoBytes = flacData?.streamInfoBytes;
|
||||
if (!streamInfoBytes) {
|
||||
// Defensive: without STREAMINFO the segment isn't decodable. This path shouldn't
|
||||
// occur in practice — tryParseHeader always populates streamInfoBytes on success.
|
||||
return rawBytes;
|
||||
}
|
||||
|
||||
// Build: fLaC (4) + STREAMINFO block (38) + audio frames.
|
||||
const result = new Uint8Array(4 + streamInfoBytes.length + rawBytes.length);
|
||||
result[0] = FLAC_MAGIC[0];
|
||||
result[1] = FLAC_MAGIC[1];
|
||||
result[2] = FLAC_MAGIC[2];
|
||||
result[3] = FLAC_MAGIC[3];
|
||||
result.set(streamInfoBytes, 4);
|
||||
result.set(rawBytes, 4 + streamInfoBytes.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
calculateByteOffset(info: FormatInfo, positionSeconds: number): number {
|
||||
const flacData = info.seekData?.kind === 'flac-seektable'
|
||||
? info.seekData as FlacSeekData : null;
|
||||
|
||||
if (flacData?.points && flacData.points.length > 0 && info.sampleRate > 0) {
|
||||
// SEEKTABLE binary search for the nearest point at or before the target sample.
|
||||
const targetSample = positionSeconds * info.sampleRate;
|
||||
const points = flacData.points;
|
||||
|
||||
let lo = 0, hi = points.length - 1, best = 0;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (points[mid].sampleNumber <= targetSample) {
|
||||
best = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
// streamOffset is bytes from start of audio data; add audioDataOffset for file-absolute.
|
||||
return info.audioDataOffset + points[best].streamOffset;
|
||||
}
|
||||
|
||||
// No SEEKTABLE: degrade to start of audio (seek restarts from beginning).
|
||||
return info.audioDataOffset;
|
||||
}
|
||||
}
|
||||
|
||||
function concat(chunks: Uint8Array[], totalSize: number): Uint8Array {
|
||||
if (chunks.length === 1) return chunks[0];
|
||||
const out = new Uint8Array(totalSize);
|
||||
let pos = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, pos);
|
||||
pos += c.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readUint32BE(buf: Uint8Array, p: number): number {
|
||||
return ((buf[p] << 24) | (buf[p + 1] << 16) | (buf[p + 2] << 8) | buf[p + 3]) >>> 0;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* FormatInfo: parsed header data needed to stream and seek an audio file.
|
||||
* Populated by IFormatDecoder.tryParseHeader; used by StreamDecoder throughout playback.
|
||||
*/
|
||||
export interface FormatInfo {
|
||||
/** Samples per second (e.g. 44100). */
|
||||
sampleRate: number;
|
||||
/** Number of audio channels. */
|
||||
channels: number;
|
||||
/**
|
||||
* Nominal bit depth — 16 for MP3 (conventional), 16/24/32 for WAV/FLAC.
|
||||
* Used for display; decoders handle the actual sample format internally.
|
||||
*/
|
||||
bitsPerSample: number;
|
||||
/**
|
||||
* Average bytes per second. Used for CBR byte-offset estimation.
|
||||
* For WAV: exact (sampleRate * blockAlign).
|
||||
* For MP3 CBR: bitrate_kbps * 125.
|
||||
* For FLAC: approximate (fileSize / duration).
|
||||
*/
|
||||
byteRate: number;
|
||||
/**
|
||||
* For WAV: PCM frame size in bytes (channels * bitsPerSample / 8).
|
||||
* For MP3: frame size in bytes (constant for CBR, 0 for VBR with TOC).
|
||||
* For FLAC: 0 (frame sizes vary; use sync scan instead).
|
||||
* Used by getAlignedSegmentSize to round to clean frame boundaries.
|
||||
*/
|
||||
blockAlign: number;
|
||||
/** Total duration in seconds, from the header (null if unavailable). */
|
||||
totalDuration: number | null;
|
||||
/** Byte offset where audio frames begin in the original file. */
|
||||
audioDataOffset: number;
|
||||
/**
|
||||
* Format-specific accelerator for seek-beyond-buffer byte calculation.
|
||||
* WAV: null (uses byteRate/blockAlign directly).
|
||||
* MP3 VBR: Xing/VBRI TOC (100-entry Uint8Array, values are file-percentage * 255).
|
||||
* FLAC: SeekTable (array of {sampleNumber: number, streamOffset: number} — stream_offset
|
||||
* is bytes from the start of audio frames, i.e. after all metadata blocks).
|
||||
*/
|
||||
seekData?: Mp3VbrSeekData | FlacSeekData | null;
|
||||
}
|
||||
|
||||
export interface Mp3VbrSeekData {
|
||||
kind: 'mp3-vbr';
|
||||
toc: Uint8Array; // 100 entries; toc[i] = file-byte-fraction at i% of duration
|
||||
totalBytes: number; // total audio bytes (from Xing header)
|
||||
}
|
||||
|
||||
export interface FlacSeekData {
|
||||
kind: 'flac-seektable';
|
||||
points: Array<{ sampleNumber: number; streamOffset: number }>;
|
||||
streamInfoBytes: Uint8Array; // raw STREAMINFO metadata block for segment wrapping
|
||||
metadataBlocksSize: number; // total bytes of all metadata blocks (for stream_offset relative math)
|
||||
}
|
||||
|
||||
/**
|
||||
* IFormatDecoder: per-format strategy for header parsing, segment boundary detection,
|
||||
* segment wrapping, and seek offset calculation.
|
||||
*
|
||||
* Implementations: WavFormatDecoder (Wave 1), Mp3FormatDecoder (Wave 2), FlacFormatDecoder (Wave 2).
|
||||
*/
|
||||
export interface IFormatDecoder {
|
||||
/**
|
||||
* Attempt to parse the header from accumulated bytes. Returns null if more bytes are needed.
|
||||
* Called with growing chunks array until it succeeds or exceeds MAX_HEADER_SEARCH_BYTES.
|
||||
*/
|
||||
tryParseHeader(chunks: Uint8Array[], totalSize: number): FormatInfo | null;
|
||||
|
||||
/**
|
||||
* Return the largest decodable byte count ≤ requestedSize that ends on a clean frame/block
|
||||
* boundary in the audio data (post-header). Returns 0 if not enough data yet.
|
||||
* @param info - the parsed FormatInfo
|
||||
* @param availableBytes - bytes available starting at the current processedBytes position
|
||||
* @param requestedSize - maximum desired segment size
|
||||
* @param streamComplete - true when the stream has ended (allows draining the tail)
|
||||
* @param rawData - optional; the first `Math.min(requestedSize, availableBytes)` raw audio
|
||||
* bytes (starting at the current processedBytes position in the stream), made available
|
||||
* for format-specific frame-sync scanning. WAV and MP3 decoders ignore this parameter;
|
||||
* FLAC and similar variable-frame formats use it to find the last clean frame boundary
|
||||
* within the candidate window.
|
||||
*/
|
||||
getAlignedSegmentSize(
|
||||
info: FormatInfo,
|
||||
availableBytes: number,
|
||||
requestedSize: number,
|
||||
streamComplete: boolean,
|
||||
rawData?: Uint8Array
|
||||
): number;
|
||||
|
||||
/**
|
||||
* Wrap raw audio bytes in the minimal decodable container for decodeAudioData.
|
||||
* WAV: prepend a 44-byte standard PCM header.
|
||||
* MP3: pass through unchanged (raw frames are self-contained).
|
||||
* FLAC: prepend fLaC marker + STREAMINFO metadata block.
|
||||
*/
|
||||
wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array;
|
||||
|
||||
/**
|
||||
* Calculate the file-absolute byte offset for a seek-beyond-buffer Range request.
|
||||
* The returned value includes the header offset (result ≥ info.audioDataOffset).
|
||||
* WAV: exact PCM frame alignment.
|
||||
* MP3 CBR: frame-aligned estimate; MP3 VBR: TOC interpolation.
|
||||
* FLAC: SEEKTABLE lookup when available.
|
||||
*/
|
||||
calculateByteOffset(info: FormatInfo, positionSeconds: number): number;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Mp3FormatDecoder - MP3 (MPEG-1/2/2.5 Layer III) implementation of IFormatDecoder.
|
||||
*
|
||||
* All MP3-specific stream logic lives here: ID3v2 skipping, MPEG frame-sync scanning,
|
||||
* frame-header decode, Xing/Info/VBRI VBR-header detection, segment sizing, and seek
|
||||
* byte-offset math (CBR estimate or VBR TOC interpolation). StreamDecoder delegates to
|
||||
* this via IFormatDecoder and holds no MP3 knowledge of its own.
|
||||
*
|
||||
* MP3 frames are self-contained, so wrapSegment is a zero-copy passthrough — the browser's
|
||||
* decodeAudioData accepts raw frame bytes directly and tolerates a partial leading frame.
|
||||
*/
|
||||
|
||||
import { FormatInfo, IFormatDecoder, Mp3VbrSeekData } from './IFormatDecoder.js';
|
||||
|
||||
export class Mp3FormatDecoder implements IFormatDecoder {
|
||||
// MPEG Layer III bitrate tables (kbps), indexed by the 4-bit bitrate index.
|
||||
// Index 0 (free) and 15 (reserved) are invalid and rejected during frame validation.
|
||||
private static readonly BITRATES_MPEG1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
|
||||
private static readonly BITRATES_MPEG2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
|
||||
|
||||
// Sample-rate tables (Hz), indexed by the 2-bit sample-rate index (3 = reserved).
|
||||
private static readonly SAMPLE_RATES_MPEG1 = [44100, 48000, 32000];
|
||||
private static readonly SAMPLE_RATES_MPEG2 = [22050, 24000, 16000];
|
||||
private static readonly SAMPLE_RATES_MPEG25 = [11025, 12000, 8000];
|
||||
|
||||
tryParseHeader(chunks: Uint8Array[], totalSize: number): FormatInfo | null {
|
||||
const buffer = Mp3FormatDecoder.concat(chunks, totalSize);
|
||||
|
||||
// Need at least the 10-byte ID3v2 header probe plus a 4-byte frame header.
|
||||
if (buffer.length < 10) return null;
|
||||
|
||||
const searchStart = Mp3FormatDecoder.id3v2Skip(buffer);
|
||||
|
||||
// Scan for the first valid MPEG Layer III frame from the skip offset.
|
||||
const frameStart = Mp3FormatDecoder.findFrameSync(buffer, searchStart);
|
||||
if (frameStart < 0) return null;
|
||||
|
||||
// Decode the 4-byte frame header.
|
||||
const h = Mp3FormatDecoder.decodeFrameHeader(buffer, frameStart);
|
||||
if (!h) return null;
|
||||
|
||||
const vbr = Mp3FormatDecoder.parseVbrHeader(buffer, frameStart, h);
|
||||
|
||||
// Xing "Xing" tag → true VBR (size-based segments, no fixed blockAlign).
|
||||
// Xing "Info" tag or no VBR header → CBR (frame-aligned blockAlign).
|
||||
const isVbr = vbr?.isXing === true;
|
||||
const blockAlign = isVbr ? 0 : h.frameSize;
|
||||
|
||||
let totalDuration: number | null = null;
|
||||
let seekData: Mp3VbrSeekData | null = null;
|
||||
if (vbr && vbr.totalFrames > 0) {
|
||||
totalDuration = vbr.totalFrames * h.samplesPerFrame / h.sampleRate;
|
||||
}
|
||||
if (vbr?.toc) {
|
||||
seekData = { kind: 'mp3-vbr', toc: vbr.toc, totalBytes: vbr.totalBytes };
|
||||
}
|
||||
|
||||
return {
|
||||
sampleRate: h.sampleRate,
|
||||
channels: h.channels,
|
||||
bitsPerSample: 16, // conventional for MP3 (decoder handles the real format internally)
|
||||
byteRate: h.bitrateKbps * 125, // bytes/sec; used for CBR seek estimate
|
||||
blockAlign,
|
||||
totalDuration,
|
||||
audioDataOffset: frameStart, // file-absolute byte where audio frames begin
|
||||
seekData
|
||||
};
|
||||
}
|
||||
|
||||
getAlignedSegmentSize(
|
||||
info: FormatInfo,
|
||||
availableBytes: number,
|
||||
requestedSize: number,
|
||||
streamComplete: boolean
|
||||
): number {
|
||||
const minSize = 4096; // at least 4 KB before starting decode
|
||||
|
||||
if (availableBytes === 0) return 0;
|
||||
|
||||
if (info.blockAlign > 0) {
|
||||
// CBR: align to complete MP3 frames so each segment is independently decodable.
|
||||
// Guard: need at least one full frame; discard sub-frame tail rather than over-reading.
|
||||
if (availableBytes < info.blockAlign) return 0;
|
||||
|
||||
const minFrames = Math.ceil(minSize / info.blockAlign);
|
||||
const availableFrames = Math.floor(availableBytes / info.blockAlign);
|
||||
if (!streamComplete && availableFrames < minFrames) return 0;
|
||||
const requestedFrames = Math.floor(Math.min(requestedSize, availableBytes) / info.blockAlign);
|
||||
// Never exceed availableBytes (clamp via requestedFrames which is floor'd from availableBytes).
|
||||
return Math.max(streamComplete ? 1 : minFrames, requestedFrames) * info.blockAlign;
|
||||
}
|
||||
|
||||
// VBR: size-based — frame sizes vary, so we cannot align cleanly. The browser MP3
|
||||
// decoder skips a partial leading frame gracefully.
|
||||
if (!streamComplete && availableBytes < minSize) return 0;
|
||||
return Math.min(requestedSize, availableBytes);
|
||||
}
|
||||
|
||||
wrapSegment(_info: FormatInfo, rawBytes: Uint8Array): Uint8Array {
|
||||
// MP3 frames are self-contained; decodeAudioData accepts raw frame data directly.
|
||||
return rawBytes;
|
||||
}
|
||||
|
||||
calculateByteOffset(info: FormatInfo, positionSeconds: number): number {
|
||||
if (info.totalDuration == null || info.totalDuration <= 0) {
|
||||
// No duration info — CBR byteRate estimate.
|
||||
return Mp3FormatDecoder.byteRateOffset(info, positionSeconds);
|
||||
}
|
||||
|
||||
const mp3Vbr = info.seekData?.kind === 'mp3-vbr' ? info.seekData as Mp3VbrSeekData : null;
|
||||
|
||||
if (mp3Vbr?.toc && mp3Vbr.totalBytes > 0) {
|
||||
// VBR with Xing TOC — interpolate file-byte fraction from the percentage table.
|
||||
const percent = Math.min(99, positionSeconds / info.totalDuration * 100);
|
||||
const tocIdx = Math.floor(percent);
|
||||
const tocFrac = percent - tocIdx;
|
||||
const t0 = mp3Vbr.toc[tocIdx];
|
||||
const t1 = tocIdx < 99 ? mp3Vbr.toc[tocIdx + 1] : 256;
|
||||
const bytePercent = (t0 + (t1 - t0) * tocFrac) / 256.0;
|
||||
return info.audioDataOffset + Math.floor(bytePercent * mp3Vbr.totalBytes);
|
||||
}
|
||||
|
||||
// VBR without TOC or CBR with duration — byteRate estimate.
|
||||
return Mp3FormatDecoder.byteRateOffset(info, positionSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* CBR/VBR-without-TOC seek estimate from average byte rate. Frame-aligns the result
|
||||
* when blockAlign is known (CBR); otherwise returns the raw byte position.
|
||||
*/
|
||||
private static byteRateOffset(info: FormatInfo, positionSeconds: number): number {
|
||||
if (info.byteRate <= 0) return info.audioDataOffset;
|
||||
const raw = Math.floor(positionSeconds * info.byteRate);
|
||||
if (info.blockAlign > 0) {
|
||||
return info.audioDataOffset + Math.floor(raw / info.blockAlign) * info.blockAlign;
|
||||
}
|
||||
return info.audioDataOffset + raw;
|
||||
}
|
||||
|
||||
/** Concatenate the accumulated chunks into one contiguous buffer. */
|
||||
private static concat(chunks: Uint8Array[], totalSize: number): Uint8Array {
|
||||
if (chunks.length === 1) return chunks[0];
|
||||
const buffer = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
buffer.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the byte offset past an ID3v2 tag if one is present at the buffer start,
|
||||
* else 0. The size field is a syncsafe big-endian uint28 (each byte's bit 7 is 0).
|
||||
*/
|
||||
private static id3v2Skip(buffer: Uint8Array): number {
|
||||
if (buffer.length < 10) return 0;
|
||||
if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) return 0; // 'I' 'D' '3'
|
||||
|
||||
const size = (buffer[6] << 21) | (buffer[7] << 14) | (buffer[8] << 7) | buffer[9];
|
||||
const hasFooter = (buffer[5] & 0x10) !== 0; // bit 4 of flags byte
|
||||
return 10 + size + (hasFooter ? 10 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan one byte at a time from `start` for the first byte position that begins a valid
|
||||
* MPEG Layer III frame. Returns the offset, or -1 if none found in the available bytes
|
||||
* (caller should wait for more data).
|
||||
*/
|
||||
private static findFrameSync(buffer: Uint8Array, start: number): number {
|
||||
// Need 4 bytes for a full frame header.
|
||||
for (let i = Math.max(0, start); i + 4 <= buffer.length; i++) {
|
||||
if (buffer[i] !== 0xff) continue;
|
||||
if ((buffer[i + 1] & 0xe0) !== 0xe0) continue; // top 3 bits of byte 1 must be set
|
||||
|
||||
const version = (buffer[i + 1] >> 3) & 3;
|
||||
const layer = (buffer[i + 1] >> 1) & 3;
|
||||
const bitrateIndex = buffer[i + 2] >> 4;
|
||||
const sampleRateIndex = (buffer[i + 2] >> 2) & 3;
|
||||
|
||||
if (version === 1) continue; // 01 = reserved
|
||||
if (layer !== 1) continue; // 01 = Layer III
|
||||
if (bitrateIndex === 0 || bitrateIndex === 15) continue; // free / reserved
|
||||
if (sampleRateIndex === 3) continue; // reserved
|
||||
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the 4-byte frame header at `frameStart`. Returns null if the resolved
|
||||
* bitrate/sample-rate are invalid (defensive — findFrameSync already validated indices).
|
||||
*/
|
||||
private static decodeFrameHeader(buffer: Uint8Array, frameStart: number): {
|
||||
version: number;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
channelMode: number;
|
||||
bitrateKbps: number;
|
||||
samplesPerFrame: number;
|
||||
frameSize: number;
|
||||
} | null {
|
||||
const b1 = buffer[frameStart + 1];
|
||||
const b2 = buffer[frameStart + 2];
|
||||
const b3 = buffer[frameStart + 3];
|
||||
|
||||
const version = (b1 >> 3) & 3; // 3 = MPEG1, 2 = MPEG2, 0 = MPEG2.5
|
||||
const bitrateIndex = b2 >> 4;
|
||||
const sampleRateIndex = (b2 >> 2) & 3;
|
||||
const paddingBit = (b2 >> 1) & 1;
|
||||
const channelMode = b3 >> 6; // 0-2 = stereo variants, 3 = mono
|
||||
|
||||
const isMpeg1 = version === 3;
|
||||
|
||||
const bitrateKbps = isMpeg1
|
||||
? Mp3FormatDecoder.BITRATES_MPEG1[bitrateIndex]
|
||||
: Mp3FormatDecoder.BITRATES_MPEG2[bitrateIndex];
|
||||
|
||||
const sampleRate = version === 3
|
||||
? Mp3FormatDecoder.SAMPLE_RATES_MPEG1[sampleRateIndex]
|
||||
: version === 2
|
||||
? Mp3FormatDecoder.SAMPLE_RATES_MPEG2[sampleRateIndex]
|
||||
: Mp3FormatDecoder.SAMPLE_RATES_MPEG25[sampleRateIndex];
|
||||
|
||||
if (!bitrateKbps || !sampleRate) return null;
|
||||
|
||||
const channels = channelMode === 3 ? 1 : 2;
|
||||
const samplesPerFrame = isMpeg1 ? 1152 : 576;
|
||||
const frameSize = Math.floor(144 * bitrateKbps * 1000 / sampleRate) + paddingBit;
|
||||
|
||||
return { version, sampleRate, channels, channelMode, bitrateKbps, samplesPerFrame, frameSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a Xing/Info (VBR or CBR-with-info) or VBRI header inside the first frame.
|
||||
* Returns null when neither is present (pure CBR).
|
||||
*/
|
||||
private static parseVbrHeader(
|
||||
buffer: Uint8Array,
|
||||
frameStart: number,
|
||||
h: { version: number; channelMode: number }
|
||||
): { isXing: boolean; totalFrames: number; totalBytes: number; toc: Uint8Array | null } | null {
|
||||
const isMpeg1 = h.version === 3;
|
||||
const isMono = h.channelMode === 3;
|
||||
|
||||
// Side-info region size depends on version and channel count.
|
||||
const sideInfoOffset = isMpeg1
|
||||
? (isMono ? 17 : 32)
|
||||
: (isMono ? 9 : 17);
|
||||
|
||||
const xing = Mp3FormatDecoder.parseXing(buffer, frameStart, sideInfoOffset);
|
||||
if (xing) return xing;
|
||||
|
||||
return Mp3FormatDecoder.parseVbri(buffer, frameStart);
|
||||
}
|
||||
|
||||
/** Parse a Xing/Info tag in the side-info region. Returns null if absent. */
|
||||
private static parseXing(
|
||||
buffer: Uint8Array,
|
||||
frameStart: number,
|
||||
sideInfoOffset: number
|
||||
): { isXing: boolean; totalFrames: number; totalBytes: number; toc: Uint8Array | null } | null {
|
||||
const tagPos = frameStart + 4 + sideInfoOffset;
|
||||
if (tagPos + 8 > buffer.length) return null;
|
||||
|
||||
const isXing = Mp3FormatDecoder.matchAscii(buffer, tagPos, 'Xing');
|
||||
const isInfo = Mp3FormatDecoder.matchAscii(buffer, tagPos, 'Info');
|
||||
if (!isXing && !isInfo) return null;
|
||||
|
||||
const flags = Mp3FormatDecoder.readUint32BE(buffer, tagPos + 4);
|
||||
|
||||
// Fields are packed in flag order: frames, bytes, TOC, quality.
|
||||
let pos = tagPos + 8;
|
||||
let totalFrames = 0;
|
||||
let totalBytes = 0;
|
||||
let toc: Uint8Array | null = null;
|
||||
|
||||
if (flags & 0x1) { // frame count present
|
||||
if (pos + 4 > buffer.length) return { isXing, totalFrames, totalBytes, toc };
|
||||
totalFrames = Mp3FormatDecoder.readUint32BE(buffer, pos);
|
||||
pos += 4;
|
||||
}
|
||||
if (flags & 0x2) { // byte count present
|
||||
if (pos + 4 > buffer.length) return { isXing, totalFrames, totalBytes, toc };
|
||||
totalBytes = Mp3FormatDecoder.readUint32BE(buffer, pos);
|
||||
pos += 4;
|
||||
}
|
||||
if (flags & 0x4) { // TOC present — only meaningful alongside a frame count
|
||||
if (pos + 100 <= buffer.length && (flags & 0x1)) {
|
||||
toc = buffer.slice(pos, pos + 100);
|
||||
}
|
||||
pos += 100;
|
||||
}
|
||||
|
||||
return { isXing, totalFrames, totalBytes, toc };
|
||||
}
|
||||
|
||||
/** Parse a VBRI tag at the fixed Fraunhofer position. Returns null if absent. */
|
||||
private static parseVbri(
|
||||
buffer: Uint8Array,
|
||||
frameStart: number
|
||||
): { isXing: boolean; totalFrames: number; totalBytes: number; toc: Uint8Array | null } | null {
|
||||
const pos = frameStart + 4 + 32;
|
||||
if (pos + 18 > buffer.length) return null;
|
||||
if (!Mp3FormatDecoder.matchAscii(buffer, pos, 'VBRI')) return null;
|
||||
|
||||
const totalFrames = Mp3FormatDecoder.readUint32BE(buffer, pos + 14);
|
||||
// VBRI is always VBR but its TOC layout differs from Xing's percentage table;
|
||||
// we surface duration only and fall back to byteRate seek estimation.
|
||||
return { isXing: true, totalFrames, totalBytes: 0, toc: null };
|
||||
}
|
||||
|
||||
private static matchAscii(buffer: Uint8Array, pos: number, tag: string): boolean {
|
||||
if (pos + tag.length > buffer.length) return false;
|
||||
for (let i = 0; i < tag.length; i++) {
|
||||
if (buffer[pos + i] !== tag.charCodeAt(i)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readUint32BE(buffer: Uint8Array, pos: number): number {
|
||||
// Unsigned: coerce the sign bit back to a positive value.
|
||||
return ((buffer[pos] << 24) | (buffer[pos + 1] << 16) | (buffer[pos + 2] << 8) | buffer[pos + 3]) >>> 0;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* StreamDecoder - Handles WAV stream parsing and AudioBuffer decoding.
|
||||
* StreamDecoder - Handles audio stream parsing and AudioBuffer decoding.
|
||||
*
|
||||
* Single Responsibility: Convert raw WAV stream data into decoded AudioBuffers.
|
||||
* Single Responsibility: Convert a raw audio stream into decoded AudioBuffers.
|
||||
* Format-specific work (header parsing, segment alignment, segment wrapping, seek
|
||||
* byte math) is delegated to an IFormatDecoder supplied at initialize time; this
|
||||
* class owns only the format-agnostic concerns: chunk accumulation, header search
|
||||
* bounding, stream-complete detection, decode timeout/retry, and range continuation.
|
||||
*/
|
||||
|
||||
import { WavHeader, WavUtils } from '../wavutils.js';
|
||||
import { AudioContextManager } from './AudioContextManager.js';
|
||||
import { FormatInfo, IFormatDecoder } from './IFormatDecoder.js';
|
||||
|
||||
export interface DecodedChunkResult {
|
||||
buffer: AudioBuffer;
|
||||
@@ -43,13 +47,14 @@ export class DecodeError extends Error {
|
||||
}
|
||||
|
||||
export class StreamDecoder {
|
||||
// Upper bound on pre-header accumulation. 256 KB is far beyond any sane WAV
|
||||
// header (including extended LIST/INFO/JUNK chunks). If we have accumulated
|
||||
// this many bytes without finding a valid header the stream is corrupt.
|
||||
// Upper bound on pre-header accumulation. 256 KB is far beyond any sane audio
|
||||
// header (WAV with extended LIST/INFO/JUNK chunks, FLAC metadata blocks, etc.).
|
||||
// If we have accumulated this many bytes without a valid header the stream is corrupt.
|
||||
private static readonly MAX_HEADER_SEARCH_BYTES = 256 * 1024;
|
||||
|
||||
private contextManager: AudioContextManager;
|
||||
private wavHeader: WavHeader | null = null;
|
||||
private formatDecoder: IFormatDecoder | null = null;
|
||||
private formatInfo: FormatInfo | null = null;
|
||||
private rawChunks: Uint8Array[] = [];
|
||||
// totalRawBytes and processedBytes are JS number (IEEE 754 double), which can
|
||||
// represent integers exactly up to 2^53 bytes (~8 PB). WAV files are bounded
|
||||
@@ -61,17 +66,17 @@ export class StreamDecoder {
|
||||
private headerError: string | null = null;
|
||||
|
||||
// Range-continuation state. After a seek-beyond-buffer the server responds 206
|
||||
// with raw PCM from a file-absolute offset (no WAV header). We retain the header
|
||||
// with raw audio from a file-absolute offset (no header). We retain the FormatInfo
|
||||
// parsed from the initial stream and treat the whole body as audio data. The
|
||||
// stream-complete check then counts raw bytes against the 206 Content-Length
|
||||
// (remainingByteLength) rather than the full-file totalStreamLength + headerSize.
|
||||
// (remainingByteLength) rather than the full-file totalStreamLength + audioDataOffset.
|
||||
private isContinuation: boolean = false;
|
||||
private remainingByteLength: number = 0;
|
||||
|
||||
// Pre-header accumulator. WAV headers can span multiple network chunks
|
||||
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
|
||||
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
|
||||
// the whole header lives in the first chunk.
|
||||
// Pre-header accumulator. Audio headers can span multiple network chunks
|
||||
// (small first segment, extended WAV LIST/INFO/JUNK chunks before 'data',
|
||||
// FLAC metadata blocks, etc.), so we buffer raw bytes here until the format
|
||||
// decoder parses a header rather than assuming it lives in the first chunk.
|
||||
private headerBytesReceived: number = 0;
|
||||
private headerSearchChunks: Uint8Array[] = [];
|
||||
|
||||
@@ -80,10 +85,12 @@ export class StreamDecoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize for a new stream
|
||||
* Initialize for a new stream. The format decoder owns all format-specific
|
||||
* parsing/wrapping/seek math for this stream's lifetime.
|
||||
*/
|
||||
initialize(totalStreamLength: number): void {
|
||||
this.wavHeader = null;
|
||||
initialize(totalStreamLength: number, formatDecoder: IFormatDecoder): void {
|
||||
this.formatDecoder = formatDecoder;
|
||||
this.formatInfo = null;
|
||||
this.rawChunks = [];
|
||||
this.totalRawBytes = 0;
|
||||
this.processedBytes = 0;
|
||||
@@ -105,12 +112,12 @@ export class StreamDecoder {
|
||||
* audio data to decode immediately.
|
||||
*/
|
||||
async processChunk(chunk: Uint8Array): Promise<DecodedChunkResult[]> {
|
||||
// If the header search already failed (corrupt/non-WAV stream), stop processing.
|
||||
// If the header search already failed (corrupt/unrecognised stream), stop processing.
|
||||
if (this.headerError) {
|
||||
throw new Error(this.headerError);
|
||||
}
|
||||
|
||||
if (!this.wavHeader) {
|
||||
if (!this.formatInfo) {
|
||||
await this.tryParseHeader(chunk);
|
||||
// Check again: tryParseHeader may have just set headerError.
|
||||
if (this.headerError) {
|
||||
@@ -135,16 +142,17 @@ export class StreamDecoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate bytes into the header-search buffer and retry parseHeader.
|
||||
* Once a header is recognised, anything past headerSize becomes audio data.
|
||||
* Accumulate bytes into the header-search buffer and retry the format decoder's
|
||||
* header parse. Once a header is recognised, anything past audioDataOffset
|
||||
* becomes audio data.
|
||||
*/
|
||||
private async tryParseHeader(chunk: Uint8Array): Promise<void> {
|
||||
this.headerSearchChunks.push(chunk);
|
||||
this.headerBytesReceived += chunk.length;
|
||||
|
||||
// Guard against unbounded accumulation from a corrupt or non-WAV stream.
|
||||
// Guard against unbounded accumulation from a corrupt or unrecognised stream.
|
||||
if (this.headerBytesReceived > StreamDecoder.MAX_HEADER_SEARCH_BYTES) {
|
||||
this.headerError = `WAV header not found after ${this.headerBytesReceived} bytes — stream may be corrupt or not a WAV file`;
|
||||
this.headerError = `Audio header not found after ${this.headerBytesReceived} bytes — stream may be corrupt or an unsupported format`;
|
||||
console.error(this.headerError);
|
||||
// Drop the search buffer so subsequent chunks are not accumulated either.
|
||||
this.headerSearchChunks = [];
|
||||
@@ -152,8 +160,8 @@ export class StreamDecoder {
|
||||
return;
|
||||
}
|
||||
|
||||
const header = WavUtils.parseHeader(this.headerSearchChunks, this.headerBytesReceived);
|
||||
if (!header) {
|
||||
const info = this.formatDecoder!.tryParseHeader(this.headerSearchChunks, this.headerBytesReceived);
|
||||
if (!info) {
|
||||
// Not enough bytes yet — wait for the next chunk. If the stream ends
|
||||
// without ever producing a valid header, the final processChunk will
|
||||
// mark streamComplete and the player will report no audio decoded;
|
||||
@@ -161,22 +169,22 @@ export class StreamDecoder {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wavHeader = header;
|
||||
this.formatInfo = info;
|
||||
|
||||
// Recreate AudioContext with correct sample rate if needed
|
||||
if (this.contextManager.sampleRate !== header.sampleRate) {
|
||||
await this.contextManager.recreateWithSampleRate(header.sampleRate);
|
||||
if (this.contextManager.sampleRate !== info.sampleRate) {
|
||||
await this.contextManager.recreateWithSampleRate(info.sampleRate);
|
||||
}
|
||||
|
||||
// Concatenate all header-search chunks and push the audio-data tail
|
||||
// (everything past headerSize) into the raw audio buffer.
|
||||
// (everything past audioDataOffset) into the raw audio buffer.
|
||||
const concatenated = new Uint8Array(this.headerBytesReceived);
|
||||
let offset = 0;
|
||||
for (const c of this.headerSearchChunks) {
|
||||
concatenated.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
const audioData = concatenated.subarray(header.headerSize);
|
||||
const audioData = concatenated.subarray(info.audioDataOffset);
|
||||
if (audioData.length > 0) {
|
||||
this.addRawData(audioData);
|
||||
}
|
||||
@@ -204,8 +212,8 @@ export class StreamDecoder {
|
||||
}
|
||||
|
||||
if (this.totalStreamLength <= 0) return;
|
||||
const totalReceived = this.wavHeader
|
||||
? this.totalRawBytes + this.wavHeader.headerSize
|
||||
const totalReceived = this.formatInfo
|
||||
? this.totalRawBytes + this.formatInfo.audioDataOffset
|
||||
: this.headerBytesReceived;
|
||||
if (totalReceived >= this.totalStreamLength) {
|
||||
this.streamComplete = true;
|
||||
@@ -232,28 +240,39 @@ export class StreamDecoder {
|
||||
* silently consume the failed segment.
|
||||
*/
|
||||
private async tryDecodeNextSegment(): Promise<DecodedChunkResult | null> {
|
||||
if (!this.wavHeader) return null;
|
||||
if (!this.formatInfo) return null;
|
||||
|
||||
const segmentSize = 64 * 1024; // 64KB segments
|
||||
const availableBytes = this.totalRawBytes - this.processedBytes;
|
||||
|
||||
// Peek the candidate window first so the aligner can scan for a format-specific
|
||||
// frame boundary (FLAC). extractAlignedData is non-destructive — it reads from
|
||||
// rawChunks without advancing processedBytes — so reading before alignment is safe.
|
||||
const peekSize = Math.min(segmentSize, availableBytes);
|
||||
if (peekSize === 0) return null;
|
||||
const peekBytes = this.extractAlignedData(peekSize);
|
||||
|
||||
// Passing streamComplete lets the aligner relax the min-frame guard
|
||||
// for the final tail; otherwise residual <512-byte tails get dropped.
|
||||
const alignedSize = WavUtils.getSampleAlignedChunkSize(
|
||||
this.wavHeader,
|
||||
segmentSize,
|
||||
const alignedSize = this.formatDecoder!.getAlignedSegmentSize(
|
||||
this.formatInfo,
|
||||
availableBytes,
|
||||
this.streamComplete
|
||||
segmentSize,
|
||||
this.streamComplete,
|
||||
peekBytes
|
||||
);
|
||||
|
||||
if (alignedSize <= 0) return null;
|
||||
|
||||
const segmentOffset = this.processedBytes;
|
||||
|
||||
const rawSegment = this.extractAlignedData(alignedSize);
|
||||
const wavFile = this.createWavFile(rawSegment);
|
||||
// alignedSize is always ≤ peekSize ≤ peekBytes.length, so subarray is in-bounds
|
||||
// and zero-copy — no second extraction needed.
|
||||
const rawSegment = peekBytes.subarray(0, alignedSize);
|
||||
const decodableSegment = this.formatDecoder!.wrapSegment(this.formatInfo, rawSegment);
|
||||
|
||||
try {
|
||||
const buffer = await this.decodeWithRetry(wavFile, segmentOffset, alignedSize);
|
||||
const buffer = await this.decodeWithRetry(decodableSegment, segmentOffset, alignedSize);
|
||||
// Advance only after a successful decode so a thrown timeout/decode
|
||||
// failure does not silently drop the segment.
|
||||
this.processedBytes += alignedSize;
|
||||
@@ -346,30 +365,19 @@ export class StreamDecoder {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete WAV file from raw audio data
|
||||
*/
|
||||
private createWavFile(rawData: Uint8Array): Uint8Array {
|
||||
const header = WavUtils.createHeader(this.wavHeader!, rawData.length);
|
||||
const wavFile = new Uint8Array(header.length + rawData.length);
|
||||
wavFile.set(header, 0);
|
||||
wavFile.set(rawData, header.length);
|
||||
return wavFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode with timeout to prevent hanging. Throws DecodeTimeoutError if the
|
||||
* deadline expires so callers can distinguish timeout from corrupt-data
|
||||
* failures (decodeAudioData throws DOMException for the latter).
|
||||
*/
|
||||
private async decodeWithTimeout(wavData: Uint8Array, timeoutMs: number = 5000): Promise<AudioBuffer> {
|
||||
const buffer = new ArrayBuffer(wavData.length);
|
||||
new Uint8Array(buffer).set(wavData);
|
||||
private async decodeWithTimeout(audioData: Uint8Array, timeoutMs: number = 5000): Promise<AudioBuffer> {
|
||||
const buffer = new ArrayBuffer(audioData.length);
|
||||
new Uint8Array(buffer).set(audioData);
|
||||
|
||||
const decodePromise = this.contextManager.decodeAudioData(buffer);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new DecodeTimeoutError(-1, wavData.length)), timeoutMs);
|
||||
timer = setTimeout(() => reject(new DecodeTimeoutError(-1, audioData.length)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -380,23 +388,28 @@ export class StreamDecoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calculated duration from WAV header
|
||||
* Get calculated duration from the parsed format header.
|
||||
*
|
||||
* Prefer the decoder's header-derived totalDuration (for WAV this is the exact
|
||||
* dataSize/byteRate). When the header omits a usable size (e.g. a WAV data chunk
|
||||
* size of 0 in a streamed/unknown-length file), fall back to deriving it from the
|
||||
* total stream length minus the header — identical to the original WAV behavior.
|
||||
*/
|
||||
getEstimatedDuration(): number | null {
|
||||
if (!this.wavHeader || this.wavHeader.byteRate <= 0) return null;
|
||||
|
||||
const audioDataSize = this.wavHeader.dataSize > 0
|
||||
? this.wavHeader.dataSize
|
||||
: (this.totalStreamLength - this.wavHeader.headerSize);
|
||||
|
||||
return audioDataSize / this.wavHeader.byteRate;
|
||||
if (!this.formatInfo) return null;
|
||||
if (this.formatInfo.totalDuration && this.formatInfo.totalDuration > 0) {
|
||||
return this.formatInfo.totalDuration;
|
||||
}
|
||||
if (this.formatInfo.byteRate <= 0) return null;
|
||||
const audioDataSize = this.totalStreamLength - this.formatInfo.audioDataOffset;
|
||||
return audioDataSize / this.formatInfo.byteRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WAV header has been parsed
|
||||
* Check if the format header has been parsed
|
||||
*/
|
||||
get headerParsed(): boolean {
|
||||
return this.wavHeader !== null;
|
||||
return this.formatInfo !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,22 +420,21 @@ export class StreamDecoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WAV header info for byte offset calculation
|
||||
* Get the parsed format info (sample rate, channels, audio-data offset, …).
|
||||
* Used by the player for seek byte-offset math and header-dependent decisions.
|
||||
*/
|
||||
getWavHeader(): WavHeader | null {
|
||||
return this.wavHeader;
|
||||
getFormatInfo(): FormatInfo | null {
|
||||
return this.formatInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate byte offset from a time position (in seconds)
|
||||
* Returns block-aligned byte offset for clean audio
|
||||
* Calculate the file-absolute byte offset for a seek to the given time position.
|
||||
* Delegates to the format decoder; the returned value is a byte position in the
|
||||
* file on disk (header included), ready for a Range request.
|
||||
*/
|
||||
calculateByteOffset(positionSeconds: number): number {
|
||||
if (!this.wavHeader || this.wavHeader.byteRate <= 0) return 0;
|
||||
|
||||
const rawOffset = Math.floor(positionSeconds * this.wavHeader.byteRate);
|
||||
// Align to block boundary for clean audio
|
||||
return Math.floor(rawOffset / this.wavHeader.blockAlign) * this.wavHeader.blockAlign;
|
||||
if (!this.formatInfo) return 0;
|
||||
return this.formatDecoder!.calculateByteOffset(this.formatInfo, positionSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,10 +465,11 @@ export class StreamDecoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset decoder state
|
||||
* Reset decoder state. The format decoder is retained — a stream's format does
|
||||
* not change across reset; a new stream supplies a fresh decoder via initialize.
|
||||
*/
|
||||
reset(): void {
|
||||
this.wavHeader = null;
|
||||
this.formatInfo = null;
|
||||
this.rawChunks = [];
|
||||
this.totalRawBytes = 0;
|
||||
this.processedBytes = 0;
|
||||
@@ -473,10 +486,10 @@ export class StreamDecoder {
|
||||
* Reinitialize for a Range-continuation stream after seek-beyond-buffer.
|
||||
*
|
||||
* The server responds to a Range request with 206 Partial Content carrying raw
|
||||
* PCM from a file-absolute offset — there is NO WAV header in this body. We retain
|
||||
* the header parsed from the initial stream (its format describes every segment we
|
||||
* synthesise via createWavFile) and feed the entire 206 body straight into the
|
||||
* decode pipeline. The `if (!this.wavHeader)` branch in processChunk therefore goes
|
||||
* audio from a file-absolute offset — there is NO header in this body. We retain
|
||||
* the FormatInfo parsed from the initial stream (its format describes every segment
|
||||
* the decoder wraps via wrapSegment) and feed the entire 206 body straight into the
|
||||
* decode pipeline. The `if (!this.formatInfo)` branch in processChunk therefore goes
|
||||
* directly to addRawData and tryParseHeader is never re-entered.
|
||||
*
|
||||
* @param remainingByteLength the Content-Length of the 206 response — the number of
|
||||
@@ -484,7 +497,7 @@ export class StreamDecoder {
|
||||
* reached when totalRawBytes >= this value.
|
||||
*/
|
||||
reinitializeForRangeContinuation(remainingByteLength: number): void {
|
||||
// Retain this.wavHeader — the 206 body carries no header to reparse.
|
||||
// Retain this.formatInfo and this.formatDecoder — the 206 body carries no header to reparse.
|
||||
this.rawChunks = [];
|
||||
this.totalRawBytes = 0;
|
||||
this.processedBytes = 0;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* WavFormatDecoder - WAV/PCM implementation of IFormatDecoder.
|
||||
*
|
||||
* All WAV-specific stream logic lives here: header parsing, frame-aligned segment
|
||||
* sizing, segment wrapping with a synthesised 44-byte PCM header, and block-aligned
|
||||
* seek byte-offset calculation. StreamDecoder delegates to this via IFormatDecoder
|
||||
* and holds no WAV knowledge of its own.
|
||||
*
|
||||
* The low-level byte routines (parseHeader, createHeader, getSampleAlignedChunkSize)
|
||||
* are reused from WavUtils rather than duplicated — this is the same proven logic the
|
||||
* decoder used before the format-agnostic refactor, kept in one place.
|
||||
*/
|
||||
|
||||
import { WavUtils } from '../wavutils.js';
|
||||
import { FormatInfo, IFormatDecoder } from './IFormatDecoder.js';
|
||||
|
||||
export class WavFormatDecoder implements IFormatDecoder {
|
||||
tryParseHeader(chunks: Uint8Array[], totalSize: number): FormatInfo | null {
|
||||
const header = WavUtils.parseHeader(chunks, totalSize);
|
||||
if (!header) return null;
|
||||
|
||||
return {
|
||||
sampleRate: header.sampleRate,
|
||||
channels: header.channels,
|
||||
bitsPerSample: header.bitsPerSample,
|
||||
byteRate: header.byteRate,
|
||||
blockAlign: header.blockAlign,
|
||||
// dataSize / byteRate is the exact PCM duration; guard against a zero
|
||||
// byteRate (malformed fmt chunk) rather than producing Infinity/NaN.
|
||||
totalDuration: header.byteRate > 0 ? header.dataSize / header.byteRate : null,
|
||||
audioDataOffset: header.headerSize,
|
||||
seekData: null
|
||||
};
|
||||
}
|
||||
|
||||
getAlignedSegmentSize(
|
||||
info: FormatInfo,
|
||||
availableBytes: number,
|
||||
requestedSize: number,
|
||||
streamComplete: boolean
|
||||
): number {
|
||||
// blockAlign is the PCM frame size; reuse the original frame-alignment routine
|
||||
// verbatim so streaming/tail-drain behavior is unchanged.
|
||||
return WavUtils.getSampleAlignedChunkSize(
|
||||
{ blockAlign: info.blockAlign },
|
||||
requestedSize,
|
||||
availableBytes,
|
||||
streamComplete
|
||||
);
|
||||
}
|
||||
|
||||
wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array {
|
||||
const header = WavUtils.createHeader(
|
||||
{
|
||||
channels: info.channels,
|
||||
sampleRate: info.sampleRate,
|
||||
byteRate: info.byteRate,
|
||||
blockAlign: info.blockAlign,
|
||||
bitsPerSample: info.bitsPerSample
|
||||
},
|
||||
rawBytes.length
|
||||
);
|
||||
const wavFile = new Uint8Array(header.length + rawBytes.length);
|
||||
wavFile.set(header, 0);
|
||||
wavFile.set(rawBytes, header.length);
|
||||
return wavFile;
|
||||
}
|
||||
|
||||
calculateByteOffset(info: FormatInfo, positionSeconds: number): number {
|
||||
// No header / malformed fmt chunk: cannot compute. Return the audio start so the
|
||||
// caller still has a valid (file-absolute) Range target rather than a negative.
|
||||
if (info.byteRate <= 0 || info.blockAlign <= 0) return info.audioDataOffset;
|
||||
|
||||
const rawOffset = Math.floor(positionSeconds * info.byteRate);
|
||||
// Align to a PCM frame boundary for clean audio, then make it file-absolute by
|
||||
// adding the header size — the Range request is a byte position in the file on disk.
|
||||
const alignedAudioOffset = Math.floor(rawOffset / info.blockAlign) * info.blockAlign;
|
||||
return info.audioDataOffset + alignedAudioOffset;
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,10 @@ const DeepDrftAudio = {
|
||||
}
|
||||
},
|
||||
|
||||
initializeStreaming: (playerId: string, totalStreamLength: number): AudioResult => {
|
||||
initializeStreaming: (playerId: string, totalStreamLength: number, contentType: string): AudioResult => {
|
||||
const player = audioPlayers.get(playerId);
|
||||
if (!player) return { success: false, error: 'Player not found' };
|
||||
return player.initializeStreaming(totalStreamLength);
|
||||
return player.initializeStreaming(totalStreamLength, contentType);
|
||||
},
|
||||
|
||||
processStreamingChunk: async (playerId: string, chunk: Uint8Array): Promise<StreamingResult> => {
|
||||
|
||||
@@ -123,7 +123,9 @@ class WavUtils {
|
||||
};
|
||||
}
|
||||
|
||||
static createHeader(wavHeader: WavHeader, dataSize: number): Uint8Array {
|
||||
static createHeader(
|
||||
wavHeader: Pick<WavHeader, 'channels' | 'sampleRate' | 'byteRate' | 'blockAlign' | 'bitsPerSample'>,
|
||||
dataSize: number): Uint8Array {
|
||||
const header = new ArrayBuffer(44);
|
||||
const view = new DataView(header);
|
||||
|
||||
@@ -195,7 +197,7 @@ class WavUtils {
|
||||
buffer[43] = (audioDataSize >> 24) & 0xFF;
|
||||
}
|
||||
|
||||
static getSampleAlignedChunkSize(header: WavHeader, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number {
|
||||
static getSampleAlignedChunkSize(header: Pick<WavHeader, 'blockAlign'>, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number {
|
||||
const frameSize = header.blockAlign;
|
||||
|
||||
// Much smaller minimum for streaming - just enough for Web Audio API.
|
||||
|
||||
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 932 KiB |
|
After Width: | Height: | Size: 1011 KiB |
|
After Width: | Height: | Size: 655 KiB |
|
After Width: | Height: | Size: 789 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 583 KiB |
@@ -0,0 +1,20 @@
|
||||
@inherits ParallaxImageBase
|
||||
|
||||
<div @ref="WindowRef"
|
||||
class="parallax-window @(FullWidth ? "full-width" : "") @Class"
|
||||
role="@(Alt1 != null ? "img" : "presentation")"
|
||||
aria-label="@Alt1"
|
||||
aria-hidden="@(Alt1 == null ? "true" : null)"
|
||||
style="@WindowStyle">
|
||||
|
||||
<div class="layer layer-1"
|
||||
style="background-image: url('@Image1'); background-size: @ImageWidth @ImageHeight;">
|
||||
</div>
|
||||
|
||||
@if (Image2 != null)
|
||||
{
|
||||
<div class="layer layer-2"
|
||||
style="background-image: url('@Image2'); background-size: @ImageWidth @ImageHeight;">
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftShared.Client.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Scroll-driven parallax image window. As the component scrolls up through the viewport,
|
||||
/// the image pans through the window faster than the page scrolls. An optional second image
|
||||
/// crossfades in on hover (pure CSS). A <see cref="FullWidth"/> flag breaks the container out
|
||||
/// of parent padding to span the viewport.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Progressive enhancement: renders a static framed image at SSR; the parallax attaches after
|
||||
/// interactive boot via <c>OnAfterRenderAsync(firstRender)</c>. When <see cref="WindowHeight"/>
|
||||
/// is left null, the window renders at 300px and the JS module recomputes height from the
|
||||
/// container width and image aspect ratio (see <see cref="WindowHeightFraction"/>), tracking
|
||||
/// container resizes — this may cause a layout shift on first paint or on orientation change. Pass an explicit
|
||||
/// <see cref="WindowHeight"/> for above-the-fold hero usage to avoid the shift.
|
||||
/// When <see cref="NaturalWidth"/> and <see cref="NaturalHeight"/> are provided, height is set
|
||||
/// via CSS <c>aspect-ratio</c> at render time — no layout shift occurs on any render mode.
|
||||
/// </remarks>
|
||||
public abstract class ParallaxImageBase : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
|
||||
/// <summary>Primary image URL, shown at rest.</summary>
|
||||
[Parameter, EditorRequired] public string Image1 { get; set; } = default!;
|
||||
|
||||
/// <summary>Optional hover image (assumed same dimensions as <see cref="Image1"/>).</summary>
|
||||
[Parameter] public string? Image2 { get; set; }
|
||||
|
||||
/// <summary>Accessible name for <see cref="Image1"/>. When null, the window is decorative.</summary>
|
||||
[Parameter] public string? Alt1 { get; set; }
|
||||
|
||||
/// <summary>Accessible name for <see cref="Image2"/> (only relevant if it adds semantic meaning).</summary>
|
||||
[Parameter] public string? Alt2 { get; set; }
|
||||
|
||||
/// <summary>CSS height of the parallax window. When null, renders at 300px then recomputes from container width, image aspect ratio, and <see cref="WindowHeightFraction"/>.</summary>
|
||||
[Parameter] public string? WindowHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Natural pixel width of <see cref="Image1"/>. When provided together with
|
||||
/// <see cref="NaturalHeight"/>, the window height is set via CSS <c>aspect-ratio</c>
|
||||
/// at render time — eliminating the SSR/hydration layout shift that occurs when
|
||||
/// dimensions are unknown. Has no effect when <see cref="WindowHeight"/> is set.
|
||||
/// </summary>
|
||||
[Parameter] public int? NaturalWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Natural pixel height of <see cref="Image1"/>. See <see cref="NaturalWidth"/>.
|
||||
/// </summary>
|
||||
[Parameter] public int? NaturalHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fraction of the aspect-ratio-correct image height to use as the parallax window height.
|
||||
/// Only active when <see cref="WindowHeight"/> is null.
|
||||
/// E.g. 0.5 (default) = window is half the height the image would be if displayed at full container width.
|
||||
/// Recalculates on container resize (orientation change, responsive layout shift).
|
||||
/// </summary>
|
||||
[Parameter] public double WindowHeightFraction { get; set; } = 0.5;
|
||||
|
||||
/// <summary><c>background-size</c> width.</summary>
|
||||
[Parameter] public string ImageWidth { get; set; } = "auto";
|
||||
|
||||
/// <summary><c>background-size</c> height.</summary>
|
||||
[Parameter] public string ImageHeight { get; set; } = "auto";
|
||||
|
||||
/// <summary>When true, stretches the container to 100vw via a negative-margin breakout.</summary>
|
||||
[Parameter] public bool FullWidth { get; set; }
|
||||
|
||||
/// <summary>Speed multiplier, clamped to [0,1] in the JS module.</summary>
|
||||
[Parameter] public double ParallaxSpeed { get; set; } = 0.5;
|
||||
|
||||
/// <summary>When false: top-on-entry to bottom-at-top. When true: inverted.</summary>
|
||||
[Parameter] public bool InvertDirection { get; set; }
|
||||
|
||||
/// <summary>Extra CSS classes on the outer element.</summary>
|
||||
[Parameter] public string? Class { get; set; }
|
||||
|
||||
protected ElementReference WindowRef;
|
||||
protected string WindowStyle { get; private set; } = "--window-height: 300px; --parallax-from: 0%; --parallax-to: 50%;";
|
||||
|
||||
private bool AspectRatioMode =>
|
||||
WindowHeight is null && NaturalWidth is > 0 && NaturalHeight is > 0;
|
||||
|
||||
private IJSObjectReference? _module;
|
||||
private string? _handle;
|
||||
|
||||
// --parallax-from/--parallax-to are inherited custom properties read by the
|
||||
// CSS @keyframes parallax-pan, which animates background-position-y on each
|
||||
// .layer (scroll-driven, pre-WASM). They encode ParallaxSpeed and
|
||||
// InvertDirection. After WASM boots, parallax.js sets data-parallax-active to
|
||||
// cancel that animation and drives background-position-y on the layers
|
||||
// directly — a clean handoff with no competing writers.
|
||||
private string ParallaxVars()
|
||||
{
|
||||
var end = (int)Math.Round(ParallaxSpeed * 100);
|
||||
end = Math.Max(1, end); // floor at 1% so the property is always valid
|
||||
return InvertDirection
|
||||
? $"--parallax-from: {end}%; --parallax-to: 0%;"
|
||||
: $"--parallax-from: 0%; --parallax-to: {end}%;";
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var pv = ParallaxVars();
|
||||
if (WindowHeight != null)
|
||||
{
|
||||
WindowStyle = $"--window-height: {WindowHeight}; {pv}";
|
||||
}
|
||||
else if (AspectRatioMode)
|
||||
{
|
||||
// Aspect-ratio mode: height: auto lets aspect-ratio take effect;
|
||||
// the inline height: auto overrides the CSS --window-height rule.
|
||||
// aspect-ratio: W / (H * fraction) → window is WindowHeightFraction
|
||||
// of the image's full display height at container width.
|
||||
var h = Math.Max(1, (int)Math.Round(NaturalHeight!.Value * WindowHeightFraction));
|
||||
WindowStyle = $"height: auto; aspect-ratio: {NaturalWidth!.Value} / {h}; {pv}";
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowStyle = $"--window-height: 300px; {pv}";
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./_content/DeepDrftShared.Client/js/parallax/parallax.js");
|
||||
|
||||
_handle = await _module.InvokeAsync<string>(
|
||||
"register",
|
||||
WindowRef,
|
||||
new
|
||||
{
|
||||
speed = ParallaxSpeed,
|
||||
invertDirection = InvertDirection,
|
||||
// heightFraction is null in explicit-height and aspect-ratio modes;
|
||||
// JS auto-height only runs in the fallback (no dimensions provided).
|
||||
heightFraction = (WindowHeight == null && !AspectRatioMode)
|
||||
? (double?)WindowHeightFraction
|
||||
: null,
|
||||
image1 = Image1
|
||||
});
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_module != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_handle != null)
|
||||
{
|
||||
await _module.InvokeVoidAsync("unregister", _handle);
|
||||
}
|
||||
|
||||
await _module.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// Circuit already torn down (e.g. browser navigated away) — nothing to clean up.
|
||||
}
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,28 @@
|
||||
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.9.3">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<Folder Include="wwwroot\js\" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- TypeScript configuration following Microsoft recommendations -->
|
||||
<PropertyGroup>
|
||||
<TypeScriptCompileOnSaveEnabled>false</TypeScriptCompileOnSaveEnabled>
|
||||
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
|
||||
<TypeScriptESModuleInterop>true</TypeScriptESModuleInterop>
|
||||
<TypeScriptAllowSyntheticDefaultImports>true</TypeScriptAllowSyntheticDefaultImports>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Prevent tsconfig.json from being copied to output directories -->
|
||||
<ItemGroup>
|
||||
<None Update="tsconfig.json">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* parallax - scroll-driven background-position panning for ParallaxImage.
|
||||
*
|
||||
* Single Responsibility: own the parallax math and scroll/observer lifecycle.
|
||||
* Blazor owns the component lifecycle and calls register/unregister. When the
|
||||
* IntersectionObserver fires and JS attaches the scroll listener, this module
|
||||
* sets data-parallax-active and immediately primes background-position-y —
|
||||
* atomically cancelling the pre-WASM CSS animation and writing the correct
|
||||
* position in the same turn, so there is no flash at the handoff.
|
||||
*/
|
||||
|
||||
interface RegisterOptions {
|
||||
speed: number; // ParallaxSpeed, clamped [0,1]
|
||||
invertDirection: boolean; // selects the formula branch
|
||||
heightFraction?: number; // null/absent = use whatever CSS sets; present = auto-compute height
|
||||
image1?: string; // primary image URL, used by the auto-height probe
|
||||
}
|
||||
|
||||
interface Handle {
|
||||
element: HTMLElement;
|
||||
options: RegisterOptions;
|
||||
observer: IntersectionObserver; // scroll gating
|
||||
resizeObserver: ResizeObserver | null; // height recalc on resize
|
||||
scrollListener: (() => void) | null;
|
||||
rafId: number | null;
|
||||
}
|
||||
|
||||
const handles = new Map<string, Handle>();
|
||||
|
||||
let _handleCounter = 0;
|
||||
|
||||
const reducedMotion = (): boolean =>
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function applyParallax(handle: Handle): void {
|
||||
const { element, options } = handle;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportH = window.innerHeight;
|
||||
|
||||
let progress = options.invertDirection
|
||||
? rect.top / viewportH
|
||||
: 1 - rect.top / viewportH;
|
||||
progress = clamp(progress, 0, 1);
|
||||
|
||||
const pos = progress * clamp(options.speed, 0, 1) * 100;
|
||||
// Write background-position-y on each layer directly — the same property the
|
||||
// pre-WASM CSS animation drives (now cancelled via data-parallax-active).
|
||||
const layers = element.querySelectorAll<HTMLElement>(':scope > .layer');
|
||||
for (const layer of layers) {
|
||||
layer.style.backgroundPositionY = `${pos}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function attachScrollListener(handle: Handle): void {
|
||||
if (handle.scrollListener || reducedMotion()) return;
|
||||
|
||||
const listener = (): void => {
|
||||
if (handle.rafId !== null) return;
|
||||
handle.rafId = requestAnimationFrame(() => {
|
||||
handle.rafId = null;
|
||||
applyParallax(handle);
|
||||
});
|
||||
};
|
||||
|
||||
handle.scrollListener = listener;
|
||||
window.addEventListener('scroll', listener, { passive: true });
|
||||
// Cancel CSS animation and prime position atomically — no gap where neither drives.
|
||||
handle.element.setAttribute('data-parallax-active', '');
|
||||
applyParallax(handle);
|
||||
}
|
||||
|
||||
function detachScrollListener(handle: Handle): void {
|
||||
if (handle.scrollListener) {
|
||||
window.removeEventListener('scroll', handle.scrollListener);
|
||||
handle.scrollListener = null;
|
||||
}
|
||||
if (handle.rafId !== null) {
|
||||
cancelAnimationFrame(handle.rafId);
|
||||
handle.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupAutoHeight(handle: Handle): void {
|
||||
if (!handle.options.heightFraction || !handle.options.image1) return;
|
||||
|
||||
const fraction = handle.options.heightFraction;
|
||||
const element = handle.element;
|
||||
|
||||
const applyHeight = (naturalW: number, naturalH: number): void => {
|
||||
const containerW = element.offsetWidth;
|
||||
if (containerW === 0 || naturalW === 0) return;
|
||||
const h = Math.round(containerW * (naturalH / naturalW) * fraction);
|
||||
element.style.setProperty('--window-height', `${h}px`);
|
||||
};
|
||||
|
||||
const probe = new Image();
|
||||
probe.onload = (): void => {
|
||||
const nw = probe.naturalWidth;
|
||||
const nh = probe.naturalHeight;
|
||||
|
||||
applyHeight(nw, nh);
|
||||
|
||||
// Watch container width changes (orientation, responsive layout, etc.)
|
||||
handle.resizeObserver = new ResizeObserver(() => {
|
||||
applyHeight(nw, nh);
|
||||
});
|
||||
handle.resizeObserver.observe(element);
|
||||
};
|
||||
probe.src = handle.options.image1;
|
||||
}
|
||||
|
||||
export function register(element: HTMLElement, options: RegisterOptions): string {
|
||||
const id = `parallax-${++_handleCounter}`;
|
||||
|
||||
const realObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
attachScrollListener(handle);
|
||||
} else {
|
||||
detachScrollListener(handle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handle: Handle = {
|
||||
element,
|
||||
options: { ...options, speed: clamp(options.speed, 0, 1) },
|
||||
observer: realObserver,
|
||||
resizeObserver: null,
|
||||
scrollListener: null,
|
||||
rafId: null,
|
||||
};
|
||||
|
||||
handle.observer.observe(element);
|
||||
|
||||
handles.set(id, handle);
|
||||
|
||||
setupAutoHeight(handle);
|
||||
|
||||
// Prime position synchronously so enhanced navigation and WASM handoff have
|
||||
// zero-frame gap. The init script covers cold page load; this covers nav.
|
||||
// Skip under reduced motion — parallax.ts never drives position then.
|
||||
if (!reducedMotion()) {
|
||||
element.setAttribute('data-parallax-active', '');
|
||||
applyParallax(handle);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function unregister(handleId: string): void {
|
||||
const handle = handles.get(handleId);
|
||||
if (!handle) return;
|
||||
|
||||
detachScrollListener(handle);
|
||||
handle.element.removeAttribute('data-parallax-active');
|
||||
handle.observer.disconnect();
|
||||
handle.resizeObserver?.disconnect();
|
||||
handles.delete(handleId);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmitOnError": true,
|
||||
"removeComments": false,
|
||||
"sourceMap": true,
|
||||
"rootDir": "Interop",
|
||||
"outDir": "wwwroot/js",
|
||||
"sourceRoot": "/Interop",
|
||||
"mapRoot": "/js"
|
||||
},
|
||||
"include": [
|
||||
"Interop/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"bin/**/*",
|
||||
"obj/**/*",
|
||||
"publish/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* ParallaxImage styles — served as a plain static asset via
|
||||
* _content/DeepDrftShared.Client/css/parallax.css.
|
||||
*
|
||||
* Why global, not scoped (.razor.css):
|
||||
* DeepDrftShared.Client is a WASM RCL referenced only by DeepDrftPublic.Client,
|
||||
* not by the DeepDrftPublic server host. Blazor merges scoped-CSS bundles only
|
||||
* from RCLs the *host* references, so this component's scoped bundle is absent
|
||||
* from DeepDrftPublic.styles.css and never reaches the SSR first paint — it
|
||||
* arrives only after WASM boots. Structural rules AND the scroll-driven
|
||||
* animation must be present at first paint, so they live here as global CSS,
|
||||
* delivered as a static web asset regardless of which project references the RCL.
|
||||
*
|
||||
* ParallaxImage is the sole producer of .parallax-window / .layer, so unscoped
|
||||
* class selectors are unambiguous.
|
||||
*/
|
||||
|
||||
.parallax-window {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: var(--window-height, 300px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.parallax-window.full-width {
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: -50vw;
|
||||
margin-right: -50vw;
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: 50%;
|
||||
background-position-y: var(--parallax-from, 0%);
|
||||
}
|
||||
|
||||
.layer-1 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.layer-2 {
|
||||
opacity: 0;
|
||||
transition: opacity 700ms ease;
|
||||
}
|
||||
|
||||
.parallax-window:hover .layer-2 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Scroll-driven parallax, present at SSR first paint (no JS, no custom-property
|
||||
* inheritance chain):
|
||||
*
|
||||
* Before WASM:
|
||||
* The view() timeline animates background-position-y on each .layer directly,
|
||||
* from --parallax-from to --parallax-to (both percentages set inline on
|
||||
* .parallax-window by the component, encoding ParallaxSpeed/InvertDirection).
|
||||
* The layer pans as the window scrolls through the viewport — correct from
|
||||
* first paint.
|
||||
*
|
||||
* After WASM:
|
||||
* JS sets data-parallax-active on .parallax-window, which cancels the CSS
|
||||
* animation (animation: none). JS then drives background-position-y via the
|
||||
* scroll listener. One writer at a time — the two never compete.
|
||||
*
|
||||
* prefers-reduced-motion:
|
||||
* animation: none → static image at --parallax-from. JS also skips its
|
||||
* scroll listener (see parallax.ts), so the image stays put.
|
||||
*/
|
||||
@supports (animation-timeline: view()) {
|
||||
@keyframes parallax-pan {
|
||||
from { background-position-y: var(--parallax-from, 0%); }
|
||||
to { background-position-y: var(--parallax-to, 0%); }
|
||||
}
|
||||
|
||||
/* Animate layers directly — no --parallax-pos inheritance chain.
|
||||
.parallax-window uses overflow: hidden, which establishes a block
|
||||
formatting context but NOT a scroll container (that needs overflow:
|
||||
scroll/auto), so view() correctly resolves to the root scroller. */
|
||||
.parallax-window > .layer {
|
||||
animation: parallax-pan linear both;
|
||||
animation-timeline: view();
|
||||
}
|
||||
|
||||
/* JS takes over on register: cancel the CSS animation so the two writers
|
||||
to background-position-y never compete. */
|
||||
.parallax-window[data-parallax-active] > .layer {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.parallax-window > .layer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.layer-2 {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* parallax-init — synchronous pre-Blazor primer for ParallaxImage.
|
||||
*
|
||||
* Why this exists (the Server→WASM "position pop"):
|
||||
* App.razor uses InteractiveAuto. On a cold WASM cache the page renders
|
||||
* server-side, the Server circuit hydrates and parallax.js register()s
|
||||
* (setting data-parallax-active, which cancels the CSS view() animation),
|
||||
* then WASM takes over: Blazor disposes the Server component → DisposeAsync
|
||||
* → unregister() → data-parallax-active is REMOVED. For the multi-frame gap
|
||||
* until WASM's new instance re-register()s (its IntersectionObserver fires
|
||||
* asynchronously), the CSS animation resumes and drives background-position-y
|
||||
* to its own view()-timeline value, which differs from the JS math — that
|
||||
* brief reversion is the visible pop.
|
||||
*
|
||||
* This script runs ONCE, synchronously, after the DOM is parsed but before
|
||||
* Blazor boots, and sets data-parallax-active on every parallax window. That
|
||||
* attribute is never tied to a component instance, so it survives the
|
||||
* Server→WASM handoff — the CSS animation stays cancelled the whole time and
|
||||
* parallax.js remains the sole writer of background-position-y across both
|
||||
* render modes. register()/unregister() lifecycle is unchanged; on genuine
|
||||
* navigation the element leaves the DOM entirely, so a stale attribute is moot.
|
||||
*
|
||||
* Why plain JS, not TypeScript:
|
||||
* A synchronous pre-Blazor primer must be a classic <script> (no module
|
||||
* graph, no async import). The RCL's TS pipeline emits ESNext modules, which
|
||||
* are deferred — too late to beat the handoff. This file is therefore a
|
||||
* hand-authored static asset, deliberately outside Interop/.
|
||||
*
|
||||
* Math parity with parallax.ts applyParallax():
|
||||
* speed and direction are recovered from the inline custom properties the
|
||||
* component already sets (--parallax-from / --parallax-to encode
|
||||
* ParallaxSpeed and InvertDirection), then the SAME progress→position formula
|
||||
* is applied. Any divergence here would itself be a pop, so the two must stay
|
||||
* in lockstep — change one, change the other.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function prime(win) {
|
||||
var styles = getComputedStyle(win);
|
||||
var from = parseFloat(styles.getPropertyValue('--parallax-from')) || 0;
|
||||
var to = parseFloat(styles.getPropertyValue('--parallax-to')) || 0;
|
||||
|
||||
// Component encodes: non-inverted → from:0, to:end; inverted → from:end, to:0.
|
||||
// Recover the original inputs from whichever endpoint carries the magnitude.
|
||||
var invert = from > to;
|
||||
var speed = clamp(Math.max(from, to) / 100, 0, 1);
|
||||
|
||||
var rect = win.getBoundingClientRect();
|
||||
var viewportH = window.innerHeight;
|
||||
|
||||
var progress = invert ? rect.top / viewportH : 1 - rect.top / viewportH;
|
||||
progress = clamp(progress, 0, 1);
|
||||
|
||||
var pos = progress * speed * 100;
|
||||
|
||||
var layers = win.querySelectorAll(':scope > .layer');
|
||||
for (var i = 0; i < layers.length; i++) {
|
||||
layers[i].style.backgroundPositionY = pos + '%';
|
||||
}
|
||||
|
||||
// Cancel the CSS animation and adopt the JS value atomically.
|
||||
win.setAttribute('data-parallax-active', '');
|
||||
}
|
||||
|
||||
function primeAll() {
|
||||
// Match parallax.ts: under reduced motion the module skips its scroll
|
||||
// listener and never sets data-parallax-active, leaving the layer at
|
||||
// --parallax-from (CSS animation is already `none` via media query).
|
||||
// Do the same here so the two paths stay consistent.
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
var windows = document.querySelectorAll('.parallax-window');
|
||||
for (var i = 0; i < windows.length; i++) {
|
||||
prime(windows[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', primeAll);
|
||||
} else {
|
||||
primeAll();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,487 @@
|
||||
using System.Text;
|
||||
using DeepDrftContent.Processors;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AudioProcessor.ProcessWavFileAsync"/> WAV format handling — standard PCM,
|
||||
/// EXTENSIBLE-PCM, EXTENSIBLE IEEE float, padded 24-in-32, and the default-fallback paths.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class AudioProcessorTests
|
||||
{
|
||||
private const ushort WaveFormatPcm = 0x0001;
|
||||
private const ushort WaveFormatExtensible = 0xFFFE;
|
||||
|
||||
private string _testDir = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "AudioProcessorTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
try { Directory.Delete(_testDir, recursive: true); }
|
||||
catch { /* Best-effort cleanup — ignore failures */ }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StandardPcm_RoundTripsUnchanged()
|
||||
{
|
||||
var path = await WriteWavAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm));
|
||||
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.GreaterThan(0.0));
|
||||
Assert.That(audio.Bitrate, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExtensiblePcm_NormalizesToStandardHeader()
|
||||
{
|
||||
var subFormat = SubFormatGuid(WaveFormatPcm);
|
||||
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
|
||||
subFormatGuid: subFormat, validBitsPerSample: 16);
|
||||
var path = await WriteWavAsync(wav);
|
||||
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.GreaterThan(0.0));
|
||||
Assert.That(audio.Bitrate, Is.GreaterThan(0));
|
||||
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm), "Stored buffer must be standard PCM");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExtensibleIeeeFloat_AcceptedAndConverted()
|
||||
{
|
||||
// Two stereo frames of 32-bit float samples (range [-1.0, 1.0]).
|
||||
var samples = FloatBytes(0.5f, -0.5f, 1.0f, -1.0f);
|
||||
var subFormat = SubFormatGuid(0x0003); // WAVE_FORMAT_IEEE_FLOAT
|
||||
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
|
||||
sampleData: samples, subFormatGuid: subFormat, validBitsPerSample: 32);
|
||||
var path = await WriteWavAsync(wav);
|
||||
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(ReadFmtBitsPerSample(audio!.Buffer), Is.EqualTo(16 + 8), "Float must convert to 24-bit PCM");
|
||||
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm));
|
||||
// 4 float samples (4 bytes each) → 4 PCM samples (3 bytes each) = 12 data bytes after the 44-byte header.
|
||||
Assert.That(audio.Buffer.Length, Is.EqualTo(44 + 12));
|
||||
// Verify the converted sample values: (int)(sample * 8388607.0), clamped, little-endian 3 bytes.
|
||||
// 0.5f → 4194303 = 0x3FFFFF → FF FF 3F
|
||||
// -0.5f → -4194303 = 0xFFC00001 → 24-bit LE: 01 00 C0
|
||||
// 1.0f → 8388607 = 0x7FFFFF → FF FF 7F
|
||||
// -1.0f → -8388607 = 0xFF800001 → 24-bit LE: 01 00 80
|
||||
var expectedData = new byte[] { 0xFF, 0xFF, 0x3F, 0x01, 0x00, 0xC0, 0xFF, 0xFF, 0x7F, 0x01, 0x00, 0x80 };
|
||||
var actualData = audio.Buffer[44..];
|
||||
Assert.That(actualData, Is.EqualTo(expectedData), "Float samples must be converted to 24-bit PCM correctly");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExtensiblePadded24in32_AcceptedAndRepacked()
|
||||
{
|
||||
// Two stereo frames; each sample is a 24-bit value stored in a 32-bit little-endian container.
|
||||
var samples = Padded24In32Bytes(0x123456, unchecked((int)0xFFEDCBA9), 0x000001, unchecked((int)0xFF800000));
|
||||
var subFormat = SubFormatGuid(WaveFormatPcm);
|
||||
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
|
||||
sampleData: samples, subFormatGuid: subFormat, validBitsPerSample: 24);
|
||||
var path = await WriteWavAsync(wav);
|
||||
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(ReadFmtBitsPerSample(audio!.Buffer), Is.EqualTo(24), "Padded container must repack to 24-bit");
|
||||
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm));
|
||||
// 4 container samples (4 bytes each) → 4 PCM samples (3 bytes each) = 12 data bytes.
|
||||
Assert.That(audio.Buffer.Length, Is.EqualTo(44 + 12));
|
||||
// Verify the repacked sample values: lowest 3 bytes of each 4-byte little-endian container.
|
||||
// 0x123456 → LE 4 bytes: 56 34 12 00 → keep 3: 56 34 12
|
||||
// 0xFFEDCBA9 → LE 4 bytes: A9 CB ED FF → keep 3: A9 CB ED
|
||||
// 0x000001 → LE 4 bytes: 01 00 00 00 → keep 3: 01 00 00
|
||||
// 0xFF800000 → LE 4 bytes: 00 00 80 FF → keep 3: 00 00 80
|
||||
var expectedData = new byte[] { 0x56, 0x34, 0x12, 0xA9, 0xCB, 0xED, 0x01, 0x00, 0x00, 0x00, 0x00, 0x80 };
|
||||
var actualData = audio.Buffer[44..];
|
||||
Assert.That(actualData, Is.EqualTo(expectedData), "Padded 24-in-32 samples must strip the padding byte correctly");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExtensibleUnsupportedSubFormat_FallsBackToDefaults()
|
||||
{
|
||||
var subFormat = SubFormatGuid(0x0005); // WAVE_FORMAT_DOLBY_AC3 — neither PCM nor float
|
||||
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
|
||||
subFormatGuid: subFormat, validBitsPerSample: 16);
|
||||
var path = await WriteWavAsync(wav);
|
||||
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unsupported SubFormat must fall back to default metadata");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ExtensibleFmtTooSmall_FallsBackToDefaults()
|
||||
{
|
||||
// audioFormat=EXTENSIBLE but fmt chunk declares 16 bytes — too small for the extension.
|
||||
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
|
||||
forceFmtChunkSize: 16);
|
||||
var path = await WriteWavAsync(wav);
|
||||
|
||||
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Undersized EXTENSIBLE fmt chunk must fall back to default metadata");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryExtractPcm_FloatWav_ReturnsNull()
|
||||
{
|
||||
var subFormat = SubFormatGuid(0x0003);
|
||||
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
|
||||
subFormatGuid: subFormat, validBitsPerSample: 32);
|
||||
var result = new AudioProcessor().TryExtractPcm(wav);
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryExtractPcm_Padded24in32_ReturnsNull()
|
||||
{
|
||||
var subFormat = SubFormatGuid(WaveFormatPcm);
|
||||
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
|
||||
subFormatGuid: subFormat, validBitsPerSample: 24);
|
||||
var result = new AudioProcessor().TryExtractPcm(wav);
|
||||
Assert.That(result, Is.Null);
|
||||
}
|
||||
|
||||
// -- MP3 ------------------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task Mp3_CbrMetadata_ParsedCorrectly()
|
||||
{
|
||||
// BuildMinimalMp3: bitrateKbps=128, sampleRate=44100, stereo=true, no Xing tag, no ID3 tag.
|
||||
// frameSize = floor(144 * 128000 / 44100) = 417 bytes; bufferSize = max(417, 48) = 417.
|
||||
// CBR duration = (bufferLength - frameStart) / (bitrateKbps * 125) = 417 / 16000 ≈ 0.0261 s.
|
||||
const int bitrateKbps = 128;
|
||||
const int sampleRate = 44100;
|
||||
var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate); // 417
|
||||
var bufferSize = Math.Max(frameSize, 4 + 32 + 12); // max(417, 48) = 417
|
||||
var expectedDuration = (double)bufferSize / (bitrateKbps * 125); // frameStart = 0 (no ID3)
|
||||
|
||||
var path = await WriteAudioAsync(BuildMinimalMp3(bitrateKbps: bitrateKbps, sampleRate: sampleRate, stereo: true), ".mp3");
|
||||
|
||||
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Extension, Is.EqualTo(".mp3"));
|
||||
Assert.That(audio.Bitrate, Is.EqualTo(bitrateKbps));
|
||||
Assert.That(audio.Duration, Is.EqualTo(expectedDuration).Within(0.01));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mp3_VbrWithXingHeader_DurationFromXing()
|
||||
{
|
||||
const int frameCount = 1000;
|
||||
const int sampleRate = 44100;
|
||||
var path = await WriteAudioAsync(
|
||||
BuildMinimalMp3(bitrateKbps: 128, sampleRate: sampleRate, stereo: true, addXingHeader: true, xingFrameCount: frameCount),
|
||||
".mp3");
|
||||
|
||||
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
var expected = (double)frameCount * 1152 / sampleRate;
|
||||
Assert.That(audio!.Duration, Is.EqualTo(expected).Within(0.001), "VBR duration must come from the Xing frame count");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mp3_InvalidFile_FallsBackToDefaults()
|
||||
{
|
||||
// A standard PCM WAV has no MPEG frame sync — the parser must fall back to defaults.
|
||||
var path = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".mp3");
|
||||
|
||||
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unparseable MP3 must fall back to default duration");
|
||||
}
|
||||
|
||||
// -- FLAC -----------------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task Flac_StreaminfoMetadata_ParsedCorrectly()
|
||||
{
|
||||
var path = await WriteAudioAsync(
|
||||
BuildMinimalFlac(sampleRate: 44100, channels: 2, bitsPerSample: 24, totalSamples: 441000),
|
||||
".flac");
|
||||
|
||||
var audio = await new FlacAudioProcessor().ProcessFlacFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Extension, Is.EqualTo(".flac"));
|
||||
Assert.That(audio.Duration, Is.EqualTo(10.0).Within(0.01), "441000 samples / 44100 Hz = 10 s");
|
||||
Assert.That(audio.Bitrate, Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Flac_InvalidFile_FallsBackToDefaults()
|
||||
{
|
||||
// A standard PCM WAV lacks the fLaC magic — the parser must fall back to defaults.
|
||||
var path = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".flac");
|
||||
|
||||
var audio = await new FlacAudioProcessor().ProcessFlacFileAsync(path);
|
||||
|
||||
Assert.That(audio, Is.Not.Null);
|
||||
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unparseable FLAC must fall back to default duration");
|
||||
}
|
||||
|
||||
// -- Router ---------------------------------------------------------------------------------
|
||||
|
||||
[Test]
|
||||
public async Task Router_DispatchesByExtension()
|
||||
{
|
||||
var router = new AudioProcessorRouter(new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor());
|
||||
|
||||
var wavPath = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".wav");
|
||||
var mp3Path = await WriteAudioAsync(BuildMinimalMp3(), ".mp3");
|
||||
var flacPath = await WriteAudioAsync(BuildMinimalFlac(), ".flac");
|
||||
|
||||
var wav = await router.ProcessAudioFileAsync(wavPath);
|
||||
var mp3 = await router.ProcessAudioFileAsync(mp3Path);
|
||||
var flac = await router.ProcessAudioFileAsync(flacPath);
|
||||
|
||||
Assert.That(wav, Is.Not.Null);
|
||||
Assert.That(wav!.Extension, Is.EqualTo(".wav"));
|
||||
Assert.That(mp3, Is.Not.Null);
|
||||
Assert.That(mp3!.Extension, Is.EqualTo(".mp3"));
|
||||
Assert.That(flac, Is.Not.Null);
|
||||
Assert.That(flac!.Extension, Is.EqualTo(".flac"));
|
||||
}
|
||||
|
||||
// -- helpers --------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises a minimal valid MPEG1 Layer III CBR MP3 buffer: one frame header plus enough body
|
||||
/// bytes for the frame, with an optional Xing VBR header in the side-information region. The body
|
||||
/// is zero-filled (silence) — only the header and any Xing tag are meaningful to the parser.
|
||||
/// </summary>
|
||||
private static byte[] BuildMinimalMp3(
|
||||
int bitrateKbps = 128,
|
||||
int sampleRate = 44100,
|
||||
bool stereo = true,
|
||||
bool addXingHeader = false,
|
||||
int xingFrameCount = 0)
|
||||
{
|
||||
// MPEG1 Layer III bitrate index for the kbps table [0,32,40,48,56,64,80,96,112,128,160,192,224,256,320].
|
||||
var bitrateIndex = Array.IndexOf(
|
||||
new[] { 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 }, bitrateKbps);
|
||||
var sampleRateIndex = Array.IndexOf(new[] { 44100, 48000, 32000 }, sampleRate);
|
||||
|
||||
// Byte 1: 1111 1011 = sync(111) version(11=MPEG1) layer(01=Layer III) protection(1=no CRC).
|
||||
const byte b1 = 0xFB;
|
||||
// Byte 2: bitrate index (4) | sample-rate index (2) | padding (1=0) | private (1=0).
|
||||
var b2 = (byte)((bitrateIndex << 4) | (sampleRateIndex << 2));
|
||||
// Byte 3: channel mode (2) | mode ext (2) | copyright (1) | original (1) | emphasis (2).
|
||||
// 00 = stereo, 11 = mono.
|
||||
var b3 = (byte)(stereo ? 0x00 : 0xC0);
|
||||
|
||||
var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate);
|
||||
|
||||
// Side-info size for MPEG1: 32 bytes stereo, 17 bytes mono. Xing tag sits just past it.
|
||||
var sideInfoSize = stereo ? 32 : 17;
|
||||
var bufferSize = Math.Max(frameSize, 4 + sideInfoSize + 12);
|
||||
var buffer = new byte[bufferSize];
|
||||
|
||||
buffer[0] = 0xFF;
|
||||
buffer[1] = b1;
|
||||
buffer[2] = b2;
|
||||
buffer[3] = b3;
|
||||
|
||||
if (addXingHeader)
|
||||
{
|
||||
var tagPos = 4 + sideInfoSize;
|
||||
buffer[tagPos] = (byte)'X';
|
||||
buffer[tagPos + 1] = (byte)'i';
|
||||
buffer[tagPos + 2] = (byte)'n';
|
||||
buffer[tagPos + 3] = (byte)'g';
|
||||
// Flags: bit 0 set = frame count present.
|
||||
buffer[tagPos + 7] = 0x01;
|
||||
// Frame count: big-endian uint32 at tag offset 8.
|
||||
buffer[tagPos + 8] = (byte)((xingFrameCount >> 24) & 0xFF);
|
||||
buffer[tagPos + 9] = (byte)((xingFrameCount >> 16) & 0xFF);
|
||||
buffer[tagPos + 10] = (byte)((xingFrameCount >> 8) & 0xFF);
|
||||
buffer[tagPos + 11] = (byte)(xingFrameCount & 0xFF);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises a minimal FLAC buffer: <c>fLaC</c> magic, a STREAMINFO metadata block header, a
|
||||
/// 34-byte STREAMINFO body carrying the sample rate, channel count, bit depth, and total sample
|
||||
/// count, plus <paramref name="audioDataBytes"/> of trailing zero bytes standing in for the encoded
|
||||
/// audio frames. The trailing bytes only affect the average-bitrate computation (file size); the
|
||||
/// parser ignores their content. Other STREAMINFO fields are left zero — the parser ignores them.
|
||||
/// </summary>
|
||||
private static byte[] BuildMinimalFlac(
|
||||
int sampleRate = 44100,
|
||||
int channels = 2,
|
||||
int bitsPerSample = 24,
|
||||
long totalSamples = 441000,
|
||||
int audioDataBytes = 256_000)
|
||||
{
|
||||
var buffer = new byte[4 + 4 + 34 + audioDataBytes];
|
||||
|
||||
// Magic.
|
||||
buffer[0] = (byte)'f';
|
||||
buffer[1] = (byte)'L';
|
||||
buffer[2] = (byte)'a';
|
||||
buffer[3] = (byte)'C';
|
||||
|
||||
// Metadata block header: byte 0 = is_last(1) | block type(7=0 STREAMINFO); bytes 1-3 = length 34.
|
||||
buffer[4] = 0x80; // last block, type 0
|
||||
buffer[5] = 0x00;
|
||||
buffer[6] = 0x00;
|
||||
buffer[7] = 34;
|
||||
|
||||
// STREAMINFO body begins at offset 8. We only set the bit-packed fields the parser reads:
|
||||
// bytes 10-12 + top nibble of 13: sample rate (20 bits)
|
||||
// bits 3-1 of byte 12: channels - 1
|
||||
// bit 0 of byte 12 + top nibble of 13: bits per sample - 1
|
||||
// low nibble of byte 13 + bytes 14-17: total samples (36 bits)
|
||||
var d = 8;
|
||||
|
||||
// 20-bit sample rate split across bytes 10, 11, and the top nibble of 12.
|
||||
buffer[d + 10] = (byte)((sampleRate >> 12) & 0xFF);
|
||||
buffer[d + 11] = (byte)((sampleRate >> 4) & 0xFF);
|
||||
|
||||
var bps = bitsPerSample - 1; // 5 bits: bit 0 of byte 12 + top 4 bits of byte 13
|
||||
var ch = channels - 1; // 3 bits: bits 3-1 of byte 12
|
||||
|
||||
// Byte 12: [sampleRate low nibble (4)] [channels-1 (3)] [bps-1 high bit (1)].
|
||||
buffer[d + 12] = (byte)(((sampleRate & 0x0F) << 4) | ((ch & 0x07) << 1) | ((bps >> 4) & 0x01));
|
||||
|
||||
// Byte 13: [bps-1 low 4 bits (4)] [total samples bits 35-32 (4)].
|
||||
buffer[d + 13] = (byte)(((bps & 0x0F) << 4) | (int)((totalSamples >> 32) & 0x0F));
|
||||
|
||||
// Bytes 14-17: total samples low 32 bits, big-endian.
|
||||
buffer[d + 14] = (byte)((totalSamples >> 24) & 0xFF);
|
||||
buffer[d + 15] = (byte)((totalSamples >> 16) & 0xFF);
|
||||
buffer[d + 16] = (byte)((totalSamples >> 8) & 0xFF);
|
||||
buffer[d + 17] = (byte)(totalSamples & 0xFF);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private async Task<string> WriteAudioAsync(byte[] bytes, string extension)
|
||||
{
|
||||
var path = Path.Combine(_testDir, Guid.NewGuid() + extension);
|
||||
await File.WriteAllBytesAsync(path, bytes);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthesises a minimal valid WAV buffer. For EXTENSIBLE (audioFormat=0xFFFE) the fmt chunk is
|
||||
/// 40 bytes and includes cbSize, wValidBitsPerSample, channel mask, and the SubFormat GUID. For
|
||||
/// standard PCM (audioFormat=1) the fmt chunk is 16 bytes. When <paramref name="sampleData"/> is
|
||||
/// null a small block of silence sized to the block alignment is used.
|
||||
/// </summary>
|
||||
private static byte[] BuildMinimalWav(
|
||||
int channels,
|
||||
int sampleRate,
|
||||
int bitsPerSample,
|
||||
ushort audioFormat,
|
||||
byte[]? sampleData = null,
|
||||
byte[]? subFormatGuid = null,
|
||||
ushort validBitsPerSample = 0,
|
||||
uint? forceFmtChunkSize = null)
|
||||
{
|
||||
var isExtensible = audioFormat == WaveFormatExtensible;
|
||||
var fmtChunkSize = forceFmtChunkSize ?? (isExtensible ? 40u : 16u);
|
||||
|
||||
var blockAlign = (ushort)(channels * (bitsPerSample / 8));
|
||||
var byteRate = (uint)(sampleRate * blockAlign);
|
||||
var data = sampleData ?? new byte[blockAlign * 2];
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("RIFF"));
|
||||
w.Write((uint)(36 + fmtChunkSize - 16 + data.Length)); // riff size adjusted for fmt extension
|
||||
w.Write(Encoding.ASCII.GetBytes("WAVE"));
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("fmt "));
|
||||
w.Write(fmtChunkSize);
|
||||
w.Write(audioFormat);
|
||||
w.Write((ushort)channels);
|
||||
w.Write((uint)sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write((ushort)bitsPerSample);
|
||||
|
||||
// Only emit the EXTENSIBLE extension when the declared fmt size actually allows for it. A
|
||||
// forced-small size (fmt=16) leaves audioFormat=EXTENSIBLE but no extension, exercising the
|
||||
// "fmt too small" fallback.
|
||||
if (fmtChunkSize >= 40)
|
||||
{
|
||||
w.Write((ushort)22); // cbSize
|
||||
w.Write(validBitsPerSample);
|
||||
w.Write((uint)0); // channel mask
|
||||
w.Write(subFormatGuid ?? SubFormatGuid(WaveFormatPcm));
|
||||
}
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("data"));
|
||||
w.Write((uint)data.Length);
|
||||
w.Write(data);
|
||||
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Builds a 16-byte SubFormat GUID whose leading 2 bytes are the format tag.</summary>
|
||||
private static byte[] SubFormatGuid(ushort formatTag)
|
||||
{
|
||||
var guid = new byte[16];
|
||||
guid[0] = (byte)(formatTag & 0xFF);
|
||||
guid[1] = (byte)((formatTag >> 8) & 0xFF);
|
||||
// Remaining 14 bytes are the fixed KSDATAFORMAT suffix; their value is irrelevant to parsing.
|
||||
return guid;
|
||||
}
|
||||
|
||||
private static byte[] FloatBytes(params float[] samples)
|
||||
{
|
||||
var bytes = new byte[samples.Length * 4];
|
||||
for (int i = 0; i < samples.Length; i++)
|
||||
{
|
||||
BitConverter.GetBytes(samples[i]).CopyTo(bytes, i * 4);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>Packs each 24-bit sample value into a 32-bit little-endian container.</summary>
|
||||
private static byte[] Padded24In32Bytes(params int[] samples)
|
||||
{
|
||||
var bytes = new byte[samples.Length * 4];
|
||||
for (int i = 0; i < samples.Length; i++)
|
||||
{
|
||||
BitConverter.GetBytes(samples[i]).CopyTo(bytes, i * 4);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task<string> WriteWavAsync(byte[] wav)
|
||||
{
|
||||
var path = Path.Combine(_testDir, Guid.NewGuid() + ".wav");
|
||||
await File.WriteAllBytesAsync(path, wav);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static ushort ReadFmtAudioFormat(byte[] standardPcmWav) => BitConverter.ToUInt16(standardPcmWav, 20);
|
||||
|
||||
private static ushort ReadFmtBitsPerSample(byte[] standardPcmWav) => BitConverter.ToUInt16(standardPcmWav, 34);
|
||||
}
|
||||
@@ -10,15 +10,18 @@ using Models.Common;
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Query-shape tests for the Phase 2.2/2.3 filter and distinct-browse repository methods.
|
||||
/// Query-shape tests for the filter and distinct-browse repository methods, updated for the
|
||||
/// Phase 8 §8.0 normalized schema: release-cardinal data (Artist, Album→Title, Genre, ImagePath)
|
||||
/// lives on <see cref="ReleaseEntity"/>, reached through the nullable Release navigation. Tracks
|
||||
/// link via <c>ReleaseId</c>; loose tracks have a null release.
|
||||
///
|
||||
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That
|
||||
/// covers exact-match equality, null passthrough, GroupBy/Count, and ordering — every predicate
|
||||
/// in <see cref="TrackRepository.GetPagedFilteredAsync"/> except the free-text branch. That branch
|
||||
/// uses <c>EF.Functions.ILike</c>, an Npgsql-only relational function with no in-memory translation,
|
||||
/// so the SearchText case is a Postgres integration test gated on a DSN (see SearchText_*). It is
|
||||
/// ignored when no test database is configured rather than asserted against a provider that never
|
||||
/// runs the predicate.
|
||||
/// covers exact-match equality through the navigation, null passthrough, GroupBy/Count, and
|
||||
/// ordering — every predicate in <see cref="TrackRepository.GetPagedFilteredAsync"/> except the
|
||||
/// free-text branch. That branch uses <c>EF.Functions.ILike</c>, an Npgsql-only relational
|
||||
/// function with no in-memory translation, so the SearchText case is a Postgres integration test
|
||||
/// gated on a DSN (see SearchText_*). It is ignored when no test database is configured rather
|
||||
/// than asserted against a provider that never runs the predicate.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class TrackFilterQueryTests
|
||||
@@ -43,16 +46,23 @@ public class TrackFilterQueryTests
|
||||
private TrackRepository CreateRepository()
|
||||
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
|
||||
|
||||
private static TrackEntity Track(
|
||||
string name, string artist, string? album = null, string? genre = null, string? image = null)
|
||||
private static ReleaseEntity Release(
|
||||
string title, string artist, string? genre = null, string? image = null)
|
||||
=> new()
|
||||
{
|
||||
Title = title,
|
||||
Artist = artist,
|
||||
Genre = genre,
|
||||
ImagePath = image,
|
||||
};
|
||||
|
||||
// A track linked to the given release (or loose when release is null).
|
||||
private static TrackEntity Track(string name, ReleaseEntity? release = null)
|
||||
=> new()
|
||||
{
|
||||
EntryKey = Guid.NewGuid().ToString("N"),
|
||||
TrackName = name,
|
||||
Artist = artist,
|
||||
Album = album,
|
||||
Genre = genre,
|
||||
ImagePath = image,
|
||||
Release = release,
|
||||
};
|
||||
|
||||
private async Task SeedAsync(params TrackEntity[] tracks)
|
||||
@@ -64,16 +74,18 @@ public class TrackFilterQueryTests
|
||||
private static PagingParameters<TrackEntity> DefaultPaging()
|
||||
=> new() { Page = 1, PageSize = 20, OrderBy = t => t.Id, IsDescending = false };
|
||||
|
||||
// Case 2 — exact album match: returns only rows whose Album equals the filter value, and
|
||||
// TotalCount reflects the filtered set, not the table.
|
||||
// Case 2 — exact album match: returns only rows whose linked release Title equals the filter
|
||||
// value, and TotalCount reflects the filtered set, not the table.
|
||||
[Test]
|
||||
public async Task GetPagedFilteredAsync_WithExactAlbum_ReturnsOnlyThatAlbum()
|
||||
{
|
||||
var blue = Release("Blue", "A");
|
||||
var red = Release("Red", "C");
|
||||
await SeedAsync(
|
||||
Track("One", "A", album: "Blue"),
|
||||
Track("Two", "B", album: "Blue"),
|
||||
Track("Three", "C", album: "Red"),
|
||||
Track("Four", "D", album: null));
|
||||
Track("One", blue),
|
||||
Track("Two", blue),
|
||||
Track("Three", red),
|
||||
Track("Four"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Album = "Blue" });
|
||||
@@ -82,14 +94,14 @@ public class TrackFilterQueryTests
|
||||
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Two" }));
|
||||
}
|
||||
|
||||
// Case 2b — exact genre match composes the same way as album.
|
||||
// Case 2b — exact genre match composes the same way as album, through the release join.
|
||||
[Test]
|
||||
public async Task GetPagedFilteredAsync_WithExactGenre_ReturnsOnlyThatGenre()
|
||||
{
|
||||
await SeedAsync(
|
||||
Track("One", "A", genre: "Techno"),
|
||||
Track("Two", "B", genre: "House"),
|
||||
Track("Three", "C", genre: "Techno"));
|
||||
Track("One", Release("A1", "A", genre: "Techno")),
|
||||
Track("Two", Release("A2", "B", genre: "House")),
|
||||
Track("Three", Release("A3", "C", genre: "Techno")));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Genre = "Techno" });
|
||||
@@ -103,9 +115,9 @@ public class TrackFilterQueryTests
|
||||
public async Task GetPagedFilteredAsync_WithNullFilter_MatchesUnfilteredPagedQuery()
|
||||
{
|
||||
await SeedAsync(
|
||||
Track("One", "A", album: "Blue"),
|
||||
Track("Two", "B", album: "Red"),
|
||||
Track("Three", "C"));
|
||||
Track("One", Release("Blue", "A")),
|
||||
Track("Two", Release("Red", "B")),
|
||||
Track("Three"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var baseline = await repo.GetPagedAsync(DefaultPaging());
|
||||
@@ -117,56 +129,98 @@ public class TrackFilterQueryTests
|
||||
Is.EqualTo(baseline.Items.Select(t => t.Id)).AsCollection);
|
||||
}
|
||||
|
||||
// Case 4 — distinct albums: excludes null-album rows, counts per group, and takes the cover from
|
||||
// the first track in the group that has a non-null ImagePath. Ordered by album ascending.
|
||||
// Case 4 — releases: returns every non-deleted release, title-ascending. Replaces the old
|
||||
// distinct-albums grouping; one row per release rather than per distinct album string.
|
||||
[Test]
|
||||
public async Task GetDistinctAlbumsAsync_GroupsCountsAndPicksCover()
|
||||
public async Task GetReleasesAsync_ReturnsAllReleasesTitleAscending()
|
||||
{
|
||||
await SeedAsync(
|
||||
Track("One", "A", album: "Zephyr", image: null),
|
||||
Track("Two", "A", album: "Zephyr", image: "cover-z"),
|
||||
Track("Three", "B", album: "Aria", image: "cover-a"),
|
||||
Track("Four", "C", album: null, image: "ignored"));
|
||||
Track("One", Release("Zephyr", "A", image: "cover-z")),
|
||||
Track("Two", Release("Aria", "B", image: "cover-a")),
|
||||
Track("Three"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var albums = await repo.GetDistinctAlbumsAsync();
|
||||
var releases = await repo.GetReleasesAsync();
|
||||
|
||||
Assert.That(albums.Select(a => a.Album), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection,
|
||||
"albums sort ascending and the null-album track is excluded");
|
||||
|
||||
var zephyr = albums.Single(a => a.Album == "Zephyr");
|
||||
Assert.That(zephyr.TrackCount, Is.EqualTo(2));
|
||||
Assert.That(zephyr.CoverImageKey, Is.EqualTo("cover-z"),
|
||||
"cover is the first non-null ImagePath in the group");
|
||||
|
||||
var aria = albums.Single(a => a.Album == "Aria");
|
||||
Assert.That(aria.TrackCount, Is.EqualTo(1));
|
||||
Assert.That(aria.CoverImageKey, Is.EqualTo("cover-a"));
|
||||
Assert.That(releases.Select(r => r.Title), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection,
|
||||
"releases sort by title ascending; the loose track contributes none");
|
||||
Assert.That(releases.Single(r => r.Title == "Zephyr").ImagePath, Is.EqualTo("cover-z"));
|
||||
Assert.That(releases.Single(r => r.Title == "Aria").ImagePath, Is.EqualTo("cover-a"));
|
||||
}
|
||||
|
||||
// Case 5 — distinct genres: excludes null-genre rows, counts per group, ordered genre ascending.
|
||||
// Case 4b — per-release track counts: keyed by ReleaseId, counting only non-deleted tracks.
|
||||
// Loose tracks (null ReleaseId) contribute no entry. Backs ReleaseDto.TrackCount.
|
||||
[Test]
|
||||
public async Task GetTrackCountsByReleaseAsync_CountsTracksPerRelease()
|
||||
{
|
||||
var zephyr = Release("Zephyr", "A");
|
||||
var aria = Release("Aria", "B");
|
||||
await SeedAsync(
|
||||
Track("One", zephyr),
|
||||
Track("Two", zephyr),
|
||||
Track("Three", aria),
|
||||
Track("Four"));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var counts = await repo.GetTrackCountsByReleaseAsync();
|
||||
|
||||
Assert.That(counts[zephyr.Id], Is.EqualTo(2));
|
||||
Assert.That(counts[aria.Id], Is.EqualTo(1));
|
||||
Assert.That(counts.Count, Is.EqualTo(2), "the loose track contributes no release key");
|
||||
}
|
||||
|
||||
// Case 5 — distinct genres: sourced from the release join, excludes releases with null genre,
|
||||
// counts tracks per genre, ordered genre ascending.
|
||||
[Test]
|
||||
public async Task GetDistinctGenresAsync_GroupsCountsAndExcludesNull()
|
||||
{
|
||||
await SeedAsync(
|
||||
Track("One", "A", genre: "Techno"),
|
||||
Track("Two", "B", genre: "Ambient"),
|
||||
Track("Three", "C", genre: "Techno"),
|
||||
Track("Four", "D", genre: null));
|
||||
Track("One", Release("A1", "A", genre: "Techno")),
|
||||
Track("Two", Release("A2", "B", genre: "Ambient")),
|
||||
Track("Three", Release("A3", "C", genre: "Techno")),
|
||||
Track("Four", Release("A4", "D")));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var genres = await repo.GetDistinctGenresAsync();
|
||||
|
||||
Assert.That(genres.Select(g => g.Genre), Is.EqualTo(new[] { "Ambient", "Techno" }).AsCollection,
|
||||
"genres sort ascending and the null-genre track is excluded");
|
||||
"genres sort ascending and the null-genre release is excluded");
|
||||
Assert.That(genres.Single(g => g.Genre == "Techno").TrackCount, Is.EqualTo(2));
|
||||
Assert.That(genres.Single(g => g.Genre == "Ambient").TrackCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
// Case 1 — free-text search across TrackName/Artist/Album, case-insensitive. EF.Functions.ILike
|
||||
// is Npgsql-only and does not translate on the in-memory provider, so this runs only against a
|
||||
// real Postgres database supplied via the DEEPDRFT_TEST_PG environment variable. Without it the
|
||||
// test is ignored rather than asserted against a provider that cannot execute the predicate.
|
||||
// Case 6 — find-or-create resolution: an existing (title, artist) returns the stored row, no
|
||||
// duplicate insert. Exercises the natural-key lookup that backs the upload FK resolution.
|
||||
[Test]
|
||||
public async Task GetReleaseByTitleAndArtistAsync_ReturnsExistingMatch()
|
||||
{
|
||||
var blue = Release("Blue", "Artist A");
|
||||
await SeedAsync(Track("One", blue));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var found = await repo.GetReleaseByTitleAndArtistAsync("Blue", "Artist A");
|
||||
|
||||
Assert.That(found, Is.Not.Null);
|
||||
Assert.That(found!.Id, Is.EqualTo(blue.Id));
|
||||
}
|
||||
|
||||
// Case 6b — no match returns null so the manager creates a fresh release.
|
||||
[Test]
|
||||
public async Task GetReleaseByTitleAndArtistAsync_ReturnsNullWhenNoMatch()
|
||||
{
|
||||
await SeedAsync(Track("One", Release("Blue", "Artist A")));
|
||||
|
||||
var repo = CreateRepository();
|
||||
var found = await repo.GetReleaseByTitleAndArtistAsync("Red", "Artist A");
|
||||
|
||||
Assert.That(found, Is.Null);
|
||||
}
|
||||
|
||||
// Case 1 — free-text search across TrackName plus the joined release Artist/Title,
|
||||
// case-insensitive. EF.Functions.ILike is Npgsql-only and does not translate on the in-memory
|
||||
// provider, so this runs only against a real Postgres database supplied via the
|
||||
// DEEPDRFT_TEST_PG environment variable. Without it the test is ignored rather than asserted
|
||||
// against a provider that cannot execute the predicate.
|
||||
[Test]
|
||||
public async Task GetPagedFilteredAsync_WithSearchText_MatchesNameArtistOrAlbumCaseInsensitive()
|
||||
{
|
||||
@@ -183,10 +237,10 @@ public class TrackFilterQueryTests
|
||||
try
|
||||
{
|
||||
pg.Tracks.AddRange(
|
||||
Track("Jazz Odyssey", "Spinal Tap", album: "Smell the Glove"),
|
||||
Track("Quiet Storm", "jazzmin", album: "Nightfall"),
|
||||
Track("Loud Noises", "Brick", album: "All JAZZ Hands"),
|
||||
Track("Unrelated", "Nobody", album: "Silence"));
|
||||
Track("Jazz Odyssey", Release("Smell the Glove", "Spinal Tap")),
|
||||
Track("Quiet Storm", Release("Nightfall", "jazzmin")),
|
||||
Track("Loud Noises", Release("All JAZZ Hands", "Brick")),
|
||||
Track("Unrelated", Release("Silence", "Nobody")));
|
||||
await pg.SaveChangesAsync();
|
||||
|
||||
var repo = new TrackRepository(
|
||||
@@ -195,7 +249,7 @@ public class TrackFilterQueryTests
|
||||
|
||||
Assert.That(result.Items.Select(t => t.TrackName),
|
||||
Is.EquivalentTo(new[] { "Jazz Odyssey", "Quiet Storm", "Loud Noises" }),
|
||||
"ILike matches 'jazz' case-insensitively in TrackName, Artist, or Album");
|
||||
"ILike matches 'jazz' case-insensitively in TrackName, release Artist, or release Title");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -23,28 +23,6 @@ What this means for the roadmap: the streaming substrate is solid. Future work c
|
||||
|
||||
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.1 Extended WAV format support
|
||||
|
||||
- **What:** Two EXTENSIBLE WAV sub-cases that were explicitly scoped out of the `WAVE_FORMAT_EXTENSIBLE` PCM fix (which shipped support for `audioFormat=0xFFFE` with a PCM SubFormat — the Bandcamp WAV download case). Both are currently rejected at `AudioProcessor.ValidateAudioParameters` and fall back to default metadata. The inline comments at `AudioProcessor.cs` (SubFormat check ~L182–188, BlockAlign note ~L225–230) mark them as accepted gaps as of that fix.
|
||||
- **EXTENSIBLE non-PCM SubFormats** — e.g. IEEE Float (32-bit float PCM, common in DAW exports). The SubFormat-GUID check accepts only PCM (`0x0001`) today; anything else is rejected outright.
|
||||
- **Padded-container EXTENSIBLE** — 24-bit valid samples in a 32-bit container (`wValidBitsPerSample=24`, container `bitsPerSample=32`). The BlockAlign check fails because the valid-bit depth (24) doesn't match the container's block align.
|
||||
- **Why it matters:** DAW exports — the dominant shape of source material as the collective uploads more of its own production — tend to be float WAV or padded 24-bit. The shipped fix covers consumer/Bandcamp WAVs but not the producer's working files.
|
||||
- **Shape:** Both live in the same seam as the shipped fix (`AudioProcessor` validation + the `NormalizeToStandardPcm` storage step), but the work differs by case:
|
||||
- *Float SubFormat:* requires float→integer sample conversion during the normalize-to-standard-PCM step (the vault stays integer-PCM so the streaming/decode pipeline is unchanged), or a Web Audio decode path that handles float directly. The conversion-at-storage option keeps the load-bearing streaming seam untouched and is the lower-risk path.
|
||||
- *Padded 24-in-32:* relax `ValidateAudioParameters` to tolerate the BlockAlign mismatch when `IsExtensible`, then normalize to the valid-bit depth (24) during storage so the stored WAV is canonical.
|
||||
- **Prerequisite:** None. Both are self-contained extensions of the WAV path that just landed; neither depends on the broader format-router work in 1.2.
|
||||
- **Relationship to 1.2:** Distinct from it. 1.2 is new *containers* (MP3, FLAC, Ogg) behind a format router; this is additional *WAV variants* on the existing PCM path. If 1.2's router lands first, these become per-variant branches inside the WAV processor rather than new processors.
|
||||
|
||||
### 1.2 Audio format diversity
|
||||
|
||||
- **What:** Today `AudioProcessor`, `WavOffsetService`, and the JS decoder are PCM/WAV-only. `MimeTypeExtensions` already 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.ProcessWavFileAsync` with a format-router that selects a per-format processor; replace `WavOffsetService` with 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 `decodeAudioData` over the full payload (loses streaming-start). The richer path is per-format chunked decoders. Worth a design pass before committing.
|
||||
- **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 `AudioBuffer`s through `decodeAudioData`. If a future path uses `MediaElementAudioSourceNode` (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.
|
||||
@@ -93,6 +71,21 @@ These follow from `CONTEXT.md §5`. Direction is strongly implied but no specifi
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — CMS Enhancements (Completed)
|
||||
|
||||
See `COMPLETED.md` for Phase 6 (§6.1, §6.3) and entity-prep (§6.2 model layer) which landed on dev in June 2026.
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Card-contextual filtering of the Tracks page — `[superseded by §8]`
|
||||
|
||||
- **What:** Make the Album and Genre dashboard cards navigate into a *filtered* `/tracks` view (e.g. clicking an album card shows only that album's tracks), rather than the unfiltered table.
|
||||
- **Why:** Turns the dashboard from a read-only summary into a navigation hub — the natural next step once the cards exist.
|
||||
- **Why deferred:** The dashboard cards aggregate *across all* albums/genres — there is no single album/genre to filter to from a top-level count card. Meaningful per-album/per-genre navigation needs an intermediate browse surface (a list of albums, a list of genres) for the admin to pick from — i.e. it's really a CMS analogue of the public `AlbumsView`/`GenresView`, not a property of the summary cards. That's a larger surface than the dashboard itself and shouldn't be smuggled in. The `GET api/track/page` endpoint already accepts `album=` and `genre=` query filters, so the API substrate is ready; the missing piece is the CMS browse UI and the filter plumbing in `TrackList`.
|
||||
- **Superseded:** **§8 (CMS Track Browser)** builds exactly the intermediate browse surface this item was waiting on — Album Mode and Genre Mode *are* the CMS analogue of `AlbumsView`/`GenresView`, and the filter plumbing into `GetPagedAsync` is part of §8's data contract. This item folds into §8; do not implement it separately.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — New content kinds
|
||||
|
||||
### 3.1 Live / session content
|
||||
@@ -131,7 +124,20 @@ These follow from `CONTEXT.md §5`. Direction is strongly implied but no specifi
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting / not yet themed
|
||||
## Phase 7 — Shared UI Components
|
||||
|
||||
Reusable presentational components in `DeepDrftShared.Client` (the RCL consumed by both the public site and the CMS). Distinct from the player stack and CMS surfaces — these are host-agnostic building blocks both apps compose.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — CMS Track Browser
|
||||
|
||||
Three browse modes for the CMS `/tracks` page — **Track**, **Album**, **Genre** — selected by a toggle, each deep-linkable so the public home page can link straight into a mode. One view-model (DI-scoped, matching the `TracksViewModel` pattern) feeds all three views; the divergence is in rendering, not data paths (per the standing "same data, different uses" preference). This supersedes the deferred §6.2 — Album and Genre modes *are* the intermediate browse surface that item was waiting on. Full spec: `product-notes/phase-8-cms-track-browser.md` (normalization gate, component decomposition, VM design, URL scheme, data contracts, open questions).
|
||||
|
||||
**§8.0 landed on 2026-06-11** — a breaking `TrackEntity` normalization has been completed and is stable on dev. §8.1–§8.5 are now unblocked. The Waveform Pre-Processing tab is **removed**, folded into an in-grid status column + per-row/page-level generate actions (see §8.2).
|
||||
|
||||
---
|
||||
|
||||
|
||||
A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now.
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# ParallaxImage — reusable scroll-parallax image window (DeepDrftShared.Client)
|
||||
|
||||
Status: spec / both open decisions resolved (§11.1 JS placement, §11.2 direction) — ready for
|
||||
implementation. Author: product-designer. Date: 2026-06-11.
|
||||
**Plan only — no code edits made by this doc.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
A thin viewport-height container that reveals different portions of an image as the
|
||||
user scrolls — the classic CSS "parallax window." As the window scrolls *up* through
|
||||
the viewport, the image pans through the window **faster than the page scrolls**, so the
|
||||
window first shows the top of the image and, by the time it reaches the top of the
|
||||
viewport, shows the bottom. An optional second image crossfades in on hover (intended
|
||||
use: grayscale at rest, colour on hover).
|
||||
|
||||
It lives in `DeepDrftShared.Client` (the shared RCL) so **both the public site and the
|
||||
CMS** can use it. That placement drove the one load-bearing decision in this spec — where the
|
||||
JS/TS interop module ships from so *both* hosts can load it — now **resolved** (TS co-located
|
||||
in the shared RCL; see §6a).
|
||||
|
||||
The effect itself is well-trodden prior art (any number of agency landing pages; the
|
||||
canonical reference is the `background-attachment: fixed` parallax, which we deliberately
|
||||
**do not** use — it is broken on iOS Safari and janky on Android). We borrow the *idiom*
|
||||
and implement it the robust way: a scroll-driven `background-position` transform gated by
|
||||
an `IntersectionObserver`, matching the project's existing "math lives in TypeScript,
|
||||
lifecycle owned by Blazor" interop pattern (mirrors how `SpectrumAnalyzer` /
|
||||
`AudioInteropService` already work).
|
||||
|
||||
---
|
||||
|
||||
## 2. Component signature
|
||||
|
||||
`DeepDrftShared.Client/Components/ParallaxImage.razor` (+ `.razor.cs`, `.razor.css`).
|
||||
|
||||
| Parameter | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `Image1` | `string` (required) | — | Primary image URL. Shown at rest. Throws/renders nothing if null or empty. |
|
||||
| `Image2` | `string?` | `null` | Optional hover image. When set, hovering crossfades `Image1`→`Image2`; mouse-out fades back. Assumed same dimensions as `Image1`. |
|
||||
| `Alt1` | `string?` | `null` | Alt text for `Image1`. See accessibility (§9). |
|
||||
| `Alt2` | `string?` | `null` | Alt text for `Image2`. |
|
||||
| `WindowHeight` | `string?` | see §4 | Height of the parallax window. Accepts any CSS length (`"300px"`, `"40vh"`). When null, resolves to the §4 fallback. |
|
||||
| `ImageWidth` | `string` | `"auto"` | `background-size` width. |
|
||||
| `ImageHeight` | `string` | `"auto"` | `background-size` height. |
|
||||
| `FullWidth` | `bool` | `false` | **Critical.** When true, the window stretches to `100vw`, breaking out of parent padding/margins (§5b). When false, it is `100%` of its parent. |
|
||||
| `ParallaxSpeed` | `double` | `0.5` | Multiplier: how much faster the image pans vs. scroll. `0` = static (no parallax), `1` = image moves with full scroll travel. Clamped to `[0,1]` (§4). |
|
||||
| `InvertDirection` | `bool` | `false` | When `false` (default): top of image visible on entry, bottom visible when the window reaches the top of the viewport (the corrected formula: `1 - rect.top/viewportH`). When `true`: inverts — bottom of image visible on entry, top visible at viewport top. Passed through to the JS module via the `register` options object (§6b). See §3. |
|
||||
| `Class` | `string?` | `null` | Extra CSS classes on the outer window, per the project's existing component convention (`Class` is the house pass-through, see `WaveformSeeker`/`SpectrumVisualizer`). |
|
||||
|
||||
The component does **not** take a separate sizing set for `Image2` — same-dimensions
|
||||
assumption per the brief.
|
||||
|
||||
---
|
||||
|
||||
## 3. Parallax math
|
||||
|
||||
The window element's vertical position in the viewport drives `background-position-y`. The
|
||||
pan **direction is a component parameter** (`InvertDirection`, §2), passed through to the JS
|
||||
module via the `register` options object (§6b). Two formula variants, keyed on that flag:
|
||||
|
||||
```
|
||||
// per scroll tick, for an in-view element:
|
||||
rect = element.getBoundingClientRect()
|
||||
viewportH = window.innerHeight
|
||||
|
||||
// progress: 0 when the window's top is at the bottom of the viewport,
|
||||
// 1 when the window's top reaches the top of the viewport.
|
||||
if (!invertDirection) {
|
||||
// DEFAULT — top of image visible on entry, bottom visible at viewport top.
|
||||
progress = 1 - (rect.top / viewportH)
|
||||
} else {
|
||||
// INVERTED — bottom of image visible on entry, top visible at viewport top
|
||||
// (this is the brief's literal form).
|
||||
progress = rect.top / viewportH
|
||||
}
|
||||
// clamp so we don't over-pan above/below the in-view band:
|
||||
progress = clamp(progress, 0, 1)
|
||||
// pan the background from top (0%) toward bottom (100%) as progress grows:
|
||||
backgroundPositionY = (progress * speed * 100) + "%"
|
||||
```
|
||||
|
||||
Notes on the two variants vs. the brief's `(element.top / viewport.height) * speed * 100%`:
|
||||
|
||||
- The brief's raw form (`rect.top / viewportH`) pans **down** as the element rises (because
|
||||
`rect.top` shrinks) — *bottom visible on entry, top visible at the top of the viewport*.
|
||||
That is now the **`InvertDirection = true`** branch.
|
||||
- The stated visual intent in the brief is the opposite — *top visible on entry, bottom
|
||||
visible at the top of the viewport* — produced by the `1 - (rect.top / viewportH)` form.
|
||||
That is the **default (`InvertDirection = false`)** branch.
|
||||
- **Resolved (Daniel, 2026-06-11):** rather than hardcode either, the direction is exposed as
|
||||
the `InvertDirection` parameter so the consumer chooses. Default is the corrected
|
||||
(top-on-entry) form.
|
||||
- `background-position: 50% Y%` keeps horizontal centred; only Y is driven.
|
||||
- `ParallaxSpeed` is clamped `[0,1]`. Above 1 the image runs out of travel and clamps to
|
||||
the bottom edge early (visible "stick"); below 0 is meaningless. Clamp, don't error.
|
||||
- `background-size` must exceed the window height for there to be anything to pan — i.e.
|
||||
the image is taller than the window (that is the whole premise of the effect). If
|
||||
`ImageHeight`/natural height ≤ `WindowHeight`, there is no pan range; the component
|
||||
still renders (static image), it just has nothing to parallax. Not an error.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sizing & defaults
|
||||
|
||||
- **`WindowHeight` default.** The brief's ideal default is "50% of `Image1` natural
|
||||
height, or `300px` fallback if natural height is unknown." Natural height is **not known
|
||||
at first server render** (no image is loaded yet, and SSR has no DOM). Resolution:
|
||||
- Render with `300px` (the safe fallback) as the initial CSS height.
|
||||
- On image load (`onload` of a hidden probe `<img>`, or the JS module reading
|
||||
`naturalHeight` once the background image decodes), if the consumer did **not** pass an
|
||||
explicit `WindowHeight`, recompute to `naturalHeight * 0.5` and update a CSS custom
|
||||
property. This is a one-time post-load adjustment, gated on "consumer left it default."
|
||||
- If the consumer passed an explicit `WindowHeight`, never override it.
|
||||
- **Trade-off:** the post-load recompute can cause a layout shift (300px → computed) on
|
||||
first paint for default-height usages. Acceptable for the at-rest hero use; if CLS
|
||||
matters for a given placement, the consumer passes an explicit `WindowHeight` and the
|
||||
shift never happens. Document this in the component's XML doc comment.
|
||||
- **`ImageWidth` / `ImageHeight`** map directly to `background-size`. `"auto"` uses
|
||||
natural dimensions. A common real config will be `ImageWidth="100%"` with
|
||||
`ImageHeight="auto"` so the image is as wide as the (possibly full-width) window and
|
||||
tall enough to pan.
|
||||
|
||||
---
|
||||
|
||||
## 5. CSS architecture
|
||||
|
||||
### 5a. Scoped vs global
|
||||
|
||||
- **Scoped (`.razor.css`)** for everything structural: the window box, the layered images,
|
||||
the crossfade transition, the `--parallax-pos` / `--window-height` custom properties.
|
||||
Blazor scoped CSS (`b-{hash}` attribute) keeps this from leaking into either host. This
|
||||
is the default and covers ~all of it.
|
||||
- **No global CSS** should be required. The full-width breakout (§5b) is achievable with
|
||||
scoped CSS + custom properties; it does not need a global rule.
|
||||
- The JS module sets **only** custom properties (`element.style.setProperty('--parallax-pos', …)`),
|
||||
never concrete CSS declarations — so all visual rules stay in the scoped stylesheet and
|
||||
the TS owns *values*, not *style*. Mirrors how `SpectrumVisualizer` drives `--bar-height`.
|
||||
|
||||
### 5b. Full-width breakout (the critical flag)
|
||||
|
||||
When `FullWidth` is true, the window must span the viewport width regardless of parent
|
||||
padding/margins. The robust, well-known technique (no JS needed for the width itself):
|
||||
|
||||
```css
|
||||
.parallax-window.full-width {
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: -50vw;
|
||||
margin-right: -50vw;
|
||||
}
|
||||
```
|
||||
|
||||
This re-centres a `100vw` element under whatever offset parent it sits in, cancelling
|
||||
ancestor padding. Two caveats to document:
|
||||
|
||||
- **Horizontal scrollbar interaction.** `100vw` includes the scrollbar gutter on some
|
||||
browsers, causing a tiny horizontal overflow. Mitigate with `overflow-x: clip` (or
|
||||
`hidden`) on a layout ancestor, or accept the hairline. Note it; don't solve it inside
|
||||
the component (it is a page-layout concern).
|
||||
- **Nested transformed ancestors.** If an ancestor has a CSS `transform`/`filter`/
|
||||
`perspective`, `position: fixed`-style escapes break — but the `100vw` + negative-margin
|
||||
technique is transform-safe, which is exactly why it is preferred over a fixed-position
|
||||
approach. Good.
|
||||
|
||||
When `FullWidth` is false: plain `width: 100%`, no breakout.
|
||||
|
||||
### 5c. Layered images & crossfade
|
||||
|
||||
Two stacked layers inside the clipped window, both using `background-image` (not `<img>`),
|
||||
so the parallax `background-position` math applies uniformly to both:
|
||||
|
||||
```
|
||||
.parallax-window // overflow:hidden; height: var(--window-height); the clip box
|
||||
.layer.layer-1 // background-image: Image1; opacity: 1
|
||||
.layer.layer-2 // background-image: Image2; opacity: 0 (only if Image2 set)
|
||||
```
|
||||
|
||||
- Both layers share `background-position-y: var(--parallax-pos)` so they pan together.
|
||||
- Crossfade is **pure CSS** (§7): `.parallax-window:hover .layer-2 { opacity: 1 }` with
|
||||
`transition: opacity 400ms ease` on `.layer-2`. Mouse-out reverses automatically — no JS.
|
||||
- When `Image2` is null, `.layer-2` is not rendered at all (no empty layer, no hover cost).
|
||||
- Rationale for `background-image` over a second `<img>`: a single position variable drives
|
||||
both layers; with `<img>` we would need `object-position` plumbing on each. Background
|
||||
layers keep the parallax seam single-sourced. The accessibility cost (background images
|
||||
are invisible to assistive tech) is handled in §9.
|
||||
|
||||
---
|
||||
|
||||
## 6. JS / TS interop seam — **and the critical placement question**
|
||||
|
||||
### 6a. The placement problem — **RESOLVED**
|
||||
|
||||
The component lives in `DeepDrftShared.Client` (RCL, consumed by **both** hosts). The brief
|
||||
placed the TS module at `DeepDrftPublic/Interop/parallax/parallax.ts`, compiled by
|
||||
`Microsoft.TypeScript.MSBuild` into `DeepDrftPublic/wwwroot/js/` — **that JS ships only from
|
||||
the public host**, leaving the CMS host (`DeepDrftManager`) with a component and no script
|
||||
behind it (silent no-op in the CMS). That conflict is what this decision resolves.
|
||||
|
||||
**Resolved (Daniel, 2026-06-11): option 1 with the TypeScript toolchain — TS all the way, no
|
||||
plain JS.** `Microsoft.TypeScript.MSBuild` is added to `DeepDrftShared.Client` and the TS
|
||||
source is co-located with the component in that RCL. RCL static assets are served from
|
||||
`_content/DeepDrftShared.Client/…` in *both* hosts automatically, so this is the only path
|
||||
where "both hosts can consume it" is true by construction.
|
||||
|
||||
Concrete placement:
|
||||
|
||||
- **TS source:** `DeepDrftShared.Client/Interop/parallax/parallax.ts` (mirrors how
|
||||
`DeepDrftPublic` has `Interop/audio/`).
|
||||
- **Compiled output:** `DeepDrftShared.Client/wwwroot/js/parallax/parallax.js` (the
|
||||
`tsconfig.json` `outDir` for the shared lib).
|
||||
- **Loaded by the component** via
|
||||
`IJSRuntime.InvokeAsync<IJSObjectReference>("import", "./_content/DeepDrftShared.Client/js/parallax/parallax.js")`.
|
||||
- **`tsconfig.json`** added to `DeepDrftShared.Client`, mirroring the one in `DeepDrftPublic`
|
||||
(`"module": "ES2022"`, `"target": "ES2020"`), and **must not** be copied to output.
|
||||
- **`DeepDrftShared.Client.csproj`** gains
|
||||
`<PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.9.3" />` (same version
|
||||
as `DeepDrftPublic.csproj`).
|
||||
|
||||
This keeps the project's "TS not raw JS" convention intact across the shared RCL rather than
|
||||
carving out a plain-JS exception. The two rejected options are recorded below for the trail:
|
||||
|
||||
- **Option 2 — compile in `DeepDrftPublic`, duplicate/link output into `DeepDrftManager`.**
|
||||
Build-time coupling: the CMS would depend on an asset produced by a sibling host and could
|
||||
ship a stale or missing copy. Rejected.
|
||||
- **Option 3 — `JSImport`/scoped per-host.** Each host owns its own copy; doubles the source.
|
||||
Rejected.
|
||||
|
||||
### 6b. Interop contract
|
||||
|
||||
The component holds an `ElementReference` to the window box and an `IJSObjectReference` to
|
||||
the imported module. Lifecycle owned by Blazor; math + listeners owned by JS — exactly the
|
||||
project's existing seam.
|
||||
|
||||
**What the JS module exposes** (ES module exports, invoked via the imported reference):
|
||||
|
||||
```ts
|
||||
// register(element, options) → handle id
|
||||
// Attaches an IntersectionObserver to `element`. While the element is intersecting,
|
||||
// a passive scroll listener updates `--parallax-pos` from the §3 math each frame
|
||||
// (rAF-throttled). While not intersecting, the scroll listener is detached.
|
||||
register(element: HTMLElement, options: {
|
||||
speed: number; // ParallaxSpeed, clamped [0,1]
|
||||
invertDirection: boolean; // InvertDirection — selects the §3 formula branch
|
||||
onNaturalHeight?: boolean // if true, module reads the bg image naturalHeight once
|
||||
// decoded and reports it back (for the §4 default)
|
||||
}): string; // returns a handle id
|
||||
|
||||
// unregister(handleId): tears down observer + scroll listener. Called from DisposeAsync.
|
||||
unregister(handleId: string): void;
|
||||
|
||||
// (optional, for §4 default) getNaturalHeight(handleId): number | null
|
||||
```
|
||||
|
||||
**What Blazor calls:**
|
||||
|
||||
- `OnAfterRenderAsync(firstRender)`: `import()` the module (once), then
|
||||
`module.invokeVoidAsync("register", _elementRef, { speed, invertDirection, onNaturalHeight })`,
|
||||
store the returned handle.
|
||||
- If `onNaturalHeight` and the consumer left `WindowHeight` default: read the reported
|
||||
natural height (either returned via a `DotNetObjectReference` callback mirroring
|
||||
`setOnProgressCallback`, or polled once via `getNaturalHeight`) and set
|
||||
`--window-height: {naturalHeight/2}px`. Callback is cleaner; mirror the audio module's
|
||||
`dotNetRef.invokeMethodAsync` pattern.
|
||||
- `DisposeAsync`: `module.invokeVoidAsync("unregister", _handle)` then dispose the module
|
||||
reference. **Must implement `IAsyncDisposable`** — a dangling scroll listener is a real
|
||||
perf leak, and the audit already established `IAsyncDisposable` discipline on the player
|
||||
provider.
|
||||
|
||||
**Performance discipline (non-negotiable in the contract):**
|
||||
|
||||
- Scroll listener is **passive** (`{ passive: true }`) and **rAF-throttled** (one
|
||||
`--parallax-pos` write per frame max, not per scroll event).
|
||||
- The listener is attached **only while the `IntersectionObserver` reports intersecting**.
|
||||
Off-screen instances cost nothing. This is the whole reason the observer is in the
|
||||
contract.
|
||||
- Multiple instances on one page each get their own handle; the module may share a single
|
||||
scroll listener that fans out to all active handles (implementation detail, not contract).
|
||||
|
||||
---
|
||||
|
||||
## 7. Hover crossfade
|
||||
|
||||
Pure CSS, no JS (§5c):
|
||||
|
||||
- `.layer-2` (only rendered when `Image2` set): `opacity: 0; transition: opacity 400ms ease`.
|
||||
- `.parallax-window:hover .layer-2 { opacity: 1; }`.
|
||||
- On mouse-out the transition reverses for free.
|
||||
- **Touch devices have no hover.** On touch, `:hover` may stick or never fire. Acceptable
|
||||
degradation: the at-rest `Image1` shows; `Image2` simply never reveals. Do **not** add a
|
||||
tap-to-toggle in the first cut (scope creep; the intended use is a desktop grayscale→colour
|
||||
flourish). Note it as a known limitation (§10).
|
||||
- The intended grayscale/colour pairing is a **content** choice (consumer supplies a
|
||||
grayscale `Image1` and a colour `Image2`); the component does not apply a CSS `filter:
|
||||
grayscale()`. Keeping it content-driven means the component stays agnostic and the pairing
|
||||
can be any two images, not only desaturate/saturate. (If Daniel would rather the component
|
||||
own the grayscale via `filter` on a single image, that is a different, simpler design —
|
||||
one image, `filter: grayscale(1)` at rest → `grayscale(0)` on hover, no `Image2` at all.
|
||||
Flagged as an alternative in §11.)
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend data / lifecycle flow
|
||||
|
||||
```
|
||||
ParallaxImage rendered (either host)
|
||||
│
|
||||
├─ SSR/first paint: static box at WindowHeight (or 300px fallback), Image1 background,
|
||||
│ --parallax-pos at its progress-0 value. No JS yet. No flash of
|
||||
│ wrong height if WindowHeight was passed explicitly.
|
||||
│
|
||||
└─ OnAfterRenderAsync(firstRender) [interactive]:
|
||||
import _content/DeepDrftShared.Client/js/parallax/parallax.js
|
||||
module.register(elementRef, { speed, onNaturalHeight })
|
||||
│
|
||||
├─ IntersectionObserver gates a passive, rAF-throttled scroll listener
|
||||
│ → writes --parallax-pos each frame while in view
|
||||
│
|
||||
└─ (if default height) reports naturalHeight → component sets --window-height
|
||||
│
|
||||
└─ DisposeAsync: module.unregister(handle); dispose module ref
|
||||
```
|
||||
|
||||
The component renders meaningfully **without** JS (static framed image) — progressive
|
||||
enhancement. The parallax is the enhancement, not the baseline. This matters for SSR and for
|
||||
the brief instant before WASM/interactive boot.
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility
|
||||
|
||||
- **`prefers-reduced-motion`.** The parallax pan is decorative motion. When
|
||||
`@media (prefers-reduced-motion: reduce)` is set, the JS module must **not** drive
|
||||
`--parallax-pos` (hold it at the progress-0/static value), and the crossfade transition
|
||||
duration collapses to `0ms` via a scoped media query. The observer/listener can simply not
|
||||
attach under reduced-motion. This is a hard accessibility requirement, not optional.
|
||||
- **Alt text.** Background images are invisible to assistive tech. Provide an accessible name
|
||||
on the window box: render a visually-hidden element (or `role="img"` + `aria-label` on the
|
||||
window) carrying `Alt1`. When `Image2` is purely a decorative hover flourish, it needs no
|
||||
separate alt (the hover is not conveying distinct information). Expose `Alt1`/`Alt2`
|
||||
parameters so the consumer decides; if both are null and the image is decorative, the
|
||||
window gets `role="presentation"` / `aria-hidden` so screen readers skip it cleanly rather
|
||||
than announcing an unnamed image.
|
||||
- **Keyboard / focus.** The component is non-interactive (no seek, no controls); it needs no
|
||||
tab stop. The hover crossfade is decorative, so its absence under keyboard nav is fine.
|
||||
- **Contrast.** If consumers overlay text on the window (a likely hero use), that is the
|
||||
consumer's contrast responsibility; the component does not own overlaid content in this cut
|
||||
(no `ChildContent` slot — see §10 future options).
|
||||
|
||||
---
|
||||
|
||||
## 10. Known edge cases & limitations
|
||||
|
||||
- **Mobile Safari.** The reason we avoid `background-attachment: fixed` entirely — it is
|
||||
broken/disabled on iOS Safari. The scroll-driven `background-position` approach works
|
||||
there. iOS momentum scrolling fires scroll events at frame cadence; the rAF throttle keeps
|
||||
it smooth.
|
||||
- **Image preload / first-paint timing.** The background image may not be decoded at first
|
||||
paint; the window shows its background colour until decode. For the natural-height default
|
||||
(§4) this also means the height recompute waits on decode → possible layout shift. Mitigate
|
||||
by encouraging explicit `WindowHeight` for above-the-fold hero usage; document it.
|
||||
- **Image shorter than window.** No pan range; renders static. Not an error (§3).
|
||||
- **Full-width horizontal overflow.** `100vw` + scrollbar gutter (§5b). A page-layout
|
||||
concern, not solved inside the component.
|
||||
- **Touch / no-hover.** `Image2` never reveals on touch (§7). Accepted limitation.
|
||||
- **Many instances on one page.** Each registers an observer; the shared scroll listener
|
||||
fans out. Verify with a stress page (e.g. 10 windows) before shipping; the in-view gating
|
||||
should keep cost flat.
|
||||
- **SSR.** No DOM at prerender; the component renders its static fallback and enhances after
|
||||
interactive boot. No `getBoundingClientRect` at SSR (would throw); all JS is behind
|
||||
`OnAfterRenderAsync(firstRender)`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Alternatives / open decisions
|
||||
|
||||
1. **JS module placement (§6a) — RESOLVED (Daniel, 2026-06-11).** TS all the way: add
|
||||
`Microsoft.TypeScript.MSBuild` to `DeepDrftShared.Client`, author the source at
|
||||
`Interop/parallax/parallax.ts`, compile to `wwwroot/js/parallax/parallax.js`, load via
|
||||
dynamic `import("./_content/DeepDrftShared.Client/js/parallax/parallax.js")`. The
|
||||
public-host-only placement (option 2) and per-host duplication (option 3) were rejected —
|
||||
only RCL co-location lets both hosts consume it. See §6a for the full resolution.
|
||||
2. **Parallax direction (§3) — RESOLVED (Daniel, 2026-06-11).** Direction is now the
|
||||
`InvertDirection` component parameter (§2) rather than a hardcoded formula; the consumer
|
||||
chooses. Default (`false`) is the corrected top-on-entry form; `true` is the brief's
|
||||
literal form. Passed to the JS module via the `register` options object (§6b).
|
||||
3. **Crossfade model (§7).** `Image2` second-image crossfade (as briefed) vs. a simpler
|
||||
single-image `filter: grayscale()` hover. Briefed design is more flexible (any two
|
||||
images); the filter design is less to wire and needs no second URL. Recommend the briefed
|
||||
two-image design; note the filter alternative exists if the only use is desaturate→colour.
|
||||
4. **`ChildContent` overlay slot — deferred.** A hero window often wants a headline laid over
|
||||
it. Not in this cut. Easy to add later as an optional `RenderFragment ChildContent`
|
||||
absolutely-positioned over the layers. Left out to keep the first cut tight; called out so
|
||||
the markup leaves room (the window box can host an overlay child without restructure).
|
||||
|
||||
---
|
||||
|
||||
## 12. File inventory
|
||||
|
||||
New:
|
||||
|
||||
- `DeepDrftShared.Client/Components/ParallaxImage.razor` (+ `.razor.cs`, `.razor.css`).
|
||||
- `DeepDrftShared.Client/Interop/parallax/parallax.ts` (§6a) — the scroll/observer module,
|
||||
authored in TypeScript.
|
||||
- `DeepDrftShared.Client/wwwroot/js/parallax/parallax.js` — compiled output of the above
|
||||
(the `tsconfig.json` `outDir`); served from `_content/DeepDrftShared.Client/…` to both hosts.
|
||||
- `DeepDrftShared.Client/tsconfig.json` — mirrors `DeepDrftPublic`'s (`"module": "ES2022"`,
|
||||
`"target": "ES2020"`); **not** copied to output.
|
||||
|
||||
Changed:
|
||||
|
||||
- `DeepDrftShared.Client.csproj` — add
|
||||
`<PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.9.3" />` (same version
|
||||
as `DeepDrftPublic.csproj`) plus the TypeScript MSBuild property/`None`-update wiring that
|
||||
keeps `tsconfig.json` out of output (mirror `DeepDrftPublic.csproj`). `wwwroot/` is packed
|
||||
as static web assets by default for an RCL.
|
||||
- Consuming pages in either host that want the effect (no change required to adopt — it is
|
||||
additive).
|
||||
|
||||
Untouched (important): the entire audio TS bundle, the player, the proxy controllers, the
|
||||
data layer. This component is self-contained and shares nothing with the playback path.
|
||||
|
||||
---
|
||||
|
||||
## 13. What this plan deliberately does NOT do
|
||||
|
||||
- Does not use `background-attachment: fixed` (broken on mobile Safari).
|
||||
- Does not add a tab stop / keyboard interaction (decorative, non-interactive).
|
||||
- Does not apply grayscale via CSS `filter` in the first cut — the grayscale/colour pairing
|
||||
is content-driven via `Image1`/`Image2` (the `filter` alternative is recorded in §11.3).
|
||||
- Does not add a `ChildContent` overlay slot in the first cut (§11.4).
|
||||
- Does not drive `--parallax-pos` under `prefers-reduced-motion` (§9).
|
||||
- Does not solve full-width horizontal overflow inside the component (a page-layout concern).
|
||||
@@ -0,0 +1,824 @@
|
||||
# Phase 8 — CMS Track Browser
|
||||
|
||||
Status: spec / one VM, three views. Review decisions folded in 2026-06-11 (waveform-in-grid,
|
||||
DI-scoped VM, BatchEdit extraction confirmed). **A data-model normalization (§0) is now a hard
|
||||
pre-requisite — it must land before any Phase 8 UI work**, sequenced as five mergeable waves
|
||||
(§0.6). Open Q3 resolved 2026-06-11: `TrackDto` gets a **nested `Release` (`ReleaseDto`)**, flat
|
||||
release fields removed, all consumers updated as part of §8.0. Author: product-designer.
|
||||
Date: 2026-06-11.
|
||||
**Plan only — no code edits made by this doc.**
|
||||
|
||||
Cross-references: `PLAN.md §8` (the concise phase entry, with §8.0 the normalization gate),
|
||||
`PLAN.md §6.2` (the deferred "card-contextual filtering" item this phase supersedes), memory
|
||||
*One source, multiple views*.
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-requisite: Phase 8.0 — `TrackEntity` Normalization
|
||||
|
||||
**This must land before any of §8.1–§8.5 UI work.** It is a breaking data-model change, not a UI
|
||||
change, and every UI section below assumes the normalized schema. Daniel required this gate during
|
||||
review on 2026-06-11.
|
||||
|
||||
### 0.1 What
|
||||
|
||||
Split the current flat `TrackEntity` into two normalized tables.
|
||||
|
||||
**`ReleaseEntity` (new)** — release-cardinal data (the "header" shared across all tracks in a
|
||||
release):
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Id` | `long` | PK |
|
||||
| `Title` | `string` | the album/release name (currently `TrackEntity.Album`) |
|
||||
| `Artist` | `string` | |
|
||||
| `Genre?` | `string` | |
|
||||
| `ReleaseDate?` | `DateOnly` | |
|
||||
| `ImagePath?` | `string` | cover art |
|
||||
| `ReleaseType` | `ReleaseType` enum | |
|
||||
| `CreatedByUserId?` | `long` | |
|
||||
|
||||
**`TrackEntity` (updated)** — track-cardinal data only:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `Id` | `long` | PK |
|
||||
| `ReleaseId` | `long` (FK → `ReleaseEntity.Id`) | nullable — see open question (1) |
|
||||
| `Release` | navigation property | |
|
||||
| `EntryKey` | `string` | FileDatabase link (immutable) |
|
||||
| `TrackName` | `string` | |
|
||||
| `TrackNumber` | `int` | 1-based |
|
||||
| `OriginalFileName?` | `string` | |
|
||||
|
||||
*Removed from `TrackEntity`:* `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
|
||||
`ReleaseType`, `CreatedByUserId` — these now live on the Release.
|
||||
|
||||
### 0.2 Why
|
||||
|
||||
The current flat schema duplicates release-level metadata (artist, album, genre, release date,
|
||||
cover art) on every track row. This was fine at launch but creates consistency hazards: updating an
|
||||
album's cover art or artist name means rewriting every track row. Phase 8 introduces album-mode
|
||||
editing (Batch Edit, album-scoped delete), so the data model should match the domain — a Release is
|
||||
a first-class entity; Tracks belong to a Release.
|
||||
|
||||
The normalization also collapses several open questions in the UI spec below:
|
||||
|
||||
- `AlbumSummaryDto` becomes a simplified `ReleaseDto` (query the Release table, not `GROUP BY`).
|
||||
- `CmsAlbumBrowser` parent rows map directly to `ReleaseEntity` rows — no derivation needed.
|
||||
- The `AlbumSummaryDto` widening question (old §6 option ii / §12.3) **dissolves entirely** — the
|
||||
Release table just has all the fields.
|
||||
- Batch Edit's "header" = the Release record; "track list" = the Track records.
|
||||
|
||||
### 0.3 Shape (direction, not implementation prescription)
|
||||
|
||||
**Migration path (breaking):**
|
||||
|
||||
1. Create `releases` table (from `ReleaseEntity`).
|
||||
2. Populate `releases` from the distinct albums in `tracks` — one release row per distinct
|
||||
`(album, artist)` identity (see open question 5). Tracks with a null album get a nullable FK or a
|
||||
synthetic "Uncategorized" release (open question 1).
|
||||
3. Add `release_id` FK column to `tracks`; populate from the newly created release rows.
|
||||
4. Drop the now-redundant columns from `tracks` (`artist`, `album`, `genre`, `release_date`,
|
||||
`image_path`, `release_type`, `created_by_user_id`). **This step is the breaking part.**
|
||||
|
||||
**DTO impact:**
|
||||
|
||||
- New `ReleaseDto` mirroring `ReleaseEntity`.
|
||||
- `TrackDto` slims down: remove `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
|
||||
`ReleaseType`, `CreatedByUserId`. Add `ReleaseId` (long) and a **nested `Release` (`ReleaseDto`)**
|
||||
property — populated on reads, ignored on writes where only the FK matters. (Open question 3
|
||||
**RESOLVED 2026-06-11**: nested `ReleaseDto`, not a flat read model. See §0.3 below.)
|
||||
- `AlbumSummaryDto` is retired / collapsed into `ReleaseDto` (open question 4).
|
||||
|
||||
**Consumer-impact list (the most consumer-visible part of this change):**
|
||||
|
||||
The existing `GET api/track/page` response shape changes. Callers that read `TrackDto.Artist` /
|
||||
`.Album` / `.Genre` / `.ReleaseDate` / `.ImagePath` / `.ReleaseType` must adjust to read those from
|
||||
the nested Release: `TrackDto.Release.Artist`, `.Release.Title` (was `.Album`), `.Release.Genre`,
|
||||
`.Release.ReleaseDate`, `.Release.ImagePath`, `.Release.ReleaseType`. **This is not a "keep the flat
|
||||
fields populated" no-op — the flat fields are removed and every reader is updated.** Known consumers
|
||||
(full per-file enumeration in §0.6 below):
|
||||
|
||||
- **`DeepDrftPublic.Client`** — the public track gallery (`TrackCard`, `TrackDetail`,
|
||||
`TrackMetaLabel`, `NowPlayingCard`) reads `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`
|
||||
for display. **This is the highest-risk consumer** — a public-facing surface, a different host,
|
||||
not part of the CMS phase otherwise. Updated in Wave 3 (§0.6).
|
||||
- **`DeepDrftManager`** — the CMS (this phase's surface). The existing `TrackEdit` / `TrackNew` /
|
||||
`BatchUpload` forms read/write the flat fields. Updated in Wave 4 (§0.6).
|
||||
|
||||
**Service / repository impact (list, not prescription):**
|
||||
|
||||
- `TrackRepository` — queries gain joins / `Include`s to load the Release.
|
||||
- `TrackManager.GetPaged` — sorting by release fields (artist, album, genre, date) needs a join.
|
||||
- `UnifiedTrackService.UploadAsync` — must find-or-create the Release before inserting the Track.
|
||||
- `TrackContentService.AddTrackAsync` / the BatchUpload flow — accepts a Release (or release id)
|
||||
rather than embedding album/artist/etc. on the track upload form.
|
||||
- The upload API endpoint form fields split: separate "release" fields from "track" fields, or the
|
||||
upload auto-creates the Release on first track and links subsequent tracks (open question 2).
|
||||
|
||||
### 0.4 Open questions (need Daniel before this lands)
|
||||
|
||||
1. **Tracks with no album.** Nullable `ReleaseId` (a "standalone track" concept), or does every
|
||||
track require a Release (a release with null/empty title)? **Recommend nullable FK** — some tracks
|
||||
genuinely have no album context.
|
||||
2. **Upload flow change.** Does uploading now require the caller to first create/identify a Release,
|
||||
or does the endpoint auto-create a Release if none exists for the given album name?
|
||||
**Recommend auto-create-or-find** (upsert on `(title, artist)`) — keeps upload-form ergonomics
|
||||
close to today's.
|
||||
3. **~~`TrackDto` shape for the public API.~~ — RESOLVED 2026-06-11.** `TrackDto` gets a **nested
|
||||
`Release` (`ReleaseDto`)** property; the release-cardinal flat fields (`Artist`, `Album`, `Genre`,
|
||||
`ReleaseDate`, `ImagePath`, `ReleaseType`, `CreatedByUserId`) are **removed** from `TrackDto` and
|
||||
live only on `ReleaseDto`. The flat read-model alternative is **rejected** — denormalizing the
|
||||
release fields back onto every `TrackDto` would re-introduce exactly the duplication §0
|
||||
normalizes away, just at the contract layer instead of the table layer. All consumers that read
|
||||
the flat fields are updated as part of §8.0 (Waves 3 + 4, §0.6) — this is mandatory, not
|
||||
follow-on. This is the most consumer-visible impact of the phase; the consumer enumeration is
|
||||
§0.6.
|
||||
4. **`AlbumSummaryDto` fate.** With a Release table, `GetAlbumSummariesAsync` becomes
|
||||
`GetReleasesAsync` returning `List<ReleaseDto>`. **Recommend retiring `AlbumSummaryDto`** in favor
|
||||
of `ReleaseDto` — a direct replacement.
|
||||
5. **Migration release identity.** Existing tracks are flat. The migration derives Release rows from
|
||||
them. If two tracks share an album name but different artists, they'd map to two releases (or one
|
||||
ambiguous one). **Recommend grouping by `(album, artist)`** as the release identity key — flag
|
||||
this edge case explicitly in the migration.
|
||||
|
||||
### 0.5 Impact on the Phase 8 UI sections below
|
||||
|
||||
With normalization landed:
|
||||
|
||||
- `CmsAlbumBrowser` parent rows **are** Release rows — the Release table has all the fields, so the
|
||||
old §6 "derive artist/genre/date/type" problem and the §12(3) "widen the DTO" question both
|
||||
disappear.
|
||||
- Batch Edit's "header" is a `ReleaseDto` form; its "track list" is `List<TrackDto>` (slim).
|
||||
- The `GetPagedAsync` album/genre filter still works — it joins through the `releases` table.
|
||||
|
||||
The UI sections (§1–§13) below are written against the pre-normalization vocabulary in places;
|
||||
where they mention `AlbumSummaryDto` widening or deriving release fields from children, **read that
|
||||
as resolved by §0** — the Release table supplies those fields directly. Net of §0, those UI
|
||||
sections get *simpler*, not harder.
|
||||
|
||||
### 0.6 Implementation sequencing — appropriately sized waves
|
||||
|
||||
§8.0 is a breaking data-model change with a wide consumer blast radius, so it is sequenced into
|
||||
independently-mergeable waves. The unit of mergeability is: *can this wave land on `dev` without
|
||||
breaking the next wave's prerequisites?* Two waves (1 + 2) are a forced exception — flagged below.
|
||||
|
||||
**Wave 1 — Data model layer (no app-code changes yet).** *Touches only `DeepDrftData`.*
|
||||
|
||||
- `ReleaseEntity` (new). `TrackEntity` updated: add `ReleaseId` FK + `Release` navigation property,
|
||||
remove the release-cardinal fields (`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
|
||||
`ReleaseType`, `CreatedByUserId`).
|
||||
- `ReleaseConfiguration` (EF Core `IEntityTypeConfiguration`): table name, column names, FK
|
||||
relationship, indexes. `TrackConfiguration` updated for the FK + removed columns.
|
||||
- EF Core migration: create `releases` table; add `release_id` FK to `tracks`; **data migration** —
|
||||
populate `releases` from distinct `(album, artist)` groups in existing `tracks` rows, then assign
|
||||
the FK back to each track; finally drop the now-redundant columns from `tracks`.
|
||||
- **CRITICAL — Wave 1 + Wave 2 are a single deployment unit.** Removing the entity fields breaks
|
||||
every DTO mapper, repository projection, and service that reads them — the solution will not
|
||||
compile at the end of Wave 1 alone. Wave 1 and Wave 2 must be developed and merged to `dev`
|
||||
**together** (one PR, or a short-lived branch merged as a unit) before anything is built or run.
|
||||
Do not attempt to land Wave 1 on its own.
|
||||
|
||||
**Wave 2 — DTOs, services, repositories, API layer.** *`DeepDrftModels`, `DeepDrftData`,
|
||||
`DeepDrftContent`, `DeepDrftAPI`.* Deploys together with Wave 1.
|
||||
|
||||
- New `ReleaseDto` (mirrors `ReleaseEntity`).
|
||||
- `TrackDto`: remove flat release fields; add `ReleaseId` (long) + `Release` (`ReleaseDto`,
|
||||
populated on reads, ignored on writes where only the FK matters).
|
||||
- `TrackRepository` (`GetPaged` / `GetById` / `Update` / etc.): queries JOIN `releases` and project
|
||||
into the updated `TrackDto` with nested `Release`. Sort-by-release-field (artist/album/genre/date)
|
||||
now sorts through the join.
|
||||
- `TrackManager` / `ITrackService`: signature updates where the changed fields surface.
|
||||
- `UnifiedTrackService.UploadAsync`: resolve a Release (**find-or-create by title + artist**) before
|
||||
inserting the Track. The upload form still sends album/artist at the top level; the service layer
|
||||
resolves the Release FK. (This is open question 2's recommended upsert, now committed.)
|
||||
- `TrackController` endpoints: request/response shapes updated where they touch the changed fields.
|
||||
- `AlbumSummaryDto` retired; `GetAlbumSummariesAsync` returns `List<ReleaseDto>` (rename to
|
||||
`GetReleasesAsync`). `ICmsTrackService.GetAlbumSummariesAsync` replaced (or aliased) accordingly.
|
||||
|
||||
**Wave 3 — Consumer updates: `DeepDrftPublic.Client` (public site).** Can run in parallel with
|
||||
Wave 4. Read off the nested `Release` everywhere the flat fields were read. Files verified against
|
||||
source 2026-06-11:
|
||||
|
||||
| File | Flat fields read today | Change |
|
||||
|---|---|---|
|
||||
| `Controls/TrackCard.razor` | `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` (grid + row modes) | → `TrackModel.Release.Artist` / `.Release.Title` / `.Release.Genre` / `.Release.ReleaseDate` / `.Release.ImagePath` |
|
||||
| `Pages/TrackDetail.razor` | `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` (masthead, cover, meta block, `hasMeta` guard) | same nested re-pointing |
|
||||
| `Controls/AudioPlayerBar/TrackMetaLabel.razor` | `Artist`, `Genre`, `ReleaseDate` (player-bar label) | → `Track.Release.*` |
|
||||
| `Controls/NowPlayingCard.razor` | `CurrentTrack.Artist`, `CurrentTrack.Album` | → `CurrentTrack.Release.Artist` / `.Release.Title` |
|
||||
|
||||
- `TrackCard.razor.cs`, `TracksGallery.razor.cs`, `TrackDetailViewModel.cs`,
|
||||
`TrackDetail.razor.cs`: pass `TrackDto` through but **do not read the flat fields** — no change
|
||||
needed beyond recompiling against the new contract. Listed so the implementer confirms rather than
|
||||
assumes.
|
||||
- **Not flat-field consumers (do not confuse):** `Pages/AlbumsView.razor` reads
|
||||
`AlbumSummaryDto.Album` and `Pages/GenresView.razor` reads `GenreSummaryDto.Genre` — these are
|
||||
*summary* DTOs, affected by the `AlbumSummaryDto` retirement in Wave 2, not by the `TrackDto`
|
||||
flat-field removal. Reconcile them when Wave 2 settles the summary-DTO shape.
|
||||
|
||||
**Wave 4 — Consumer updates: `DeepDrftManager` (existing CMS surfaces).** Can run in parallel with
|
||||
Wave 3. These must work against the normalized model *before* the Phase 8 UI work (Wave 5) starts.
|
||||
|
||||
| File | Today | Change |
|
||||
|---|---|---|
|
||||
| `Components/Pages/Tracks/TrackEdit.razor` | `TrackEditForm.From(track)` reads `track.Artist/.Album/.Genre/.ImagePath/.ReleaseDate/.ReleaseType`; `SaveAsync` calls `UpdateAsync` with the flat args | read from `track.Release.*`; `UpdateAsync` path follows Wave 2's signature (still passes album/artist, service resolves the Release) |
|
||||
| `Components/Pages/Tracks/TrackNew.razor` | binds `_album`/`_artist`/`_genre`/`_releaseDate`, calls `UploadTrackAsync` + cover-link `UpdateAsync` | form fields can stay; the upload service now auto-resolves the Release (Wave 2). Binding unchanged externally, resolution changes internally |
|
||||
| `Components/Pages/Tracks/BatchUpload.razor` | album-header flat fields sent on upload | same — service auto-creates/finds the Release; form fields stay, internal binding adjusts |
|
||||
| `Components/Pages/Tracks/TrackList.razor` | the `MudTable<TrackDto>` columns bind `Artist`/`Album`/`Genre`/`ReleaseDate` | re-point to `.Release.*`. **Note:** this grid is superseded by §8.2 `CmsTrackGrid` in Wave 5 — update it minimally in Wave 4 only to keep `dev` green, since Wave 5 rebuilds it |
|
||||
|
||||
- Most CMS surfaces touched here are *rebuilt* in Wave 5 (Phase 8 UI). Wave 4's job is the minimum
|
||||
to keep the existing CMS compiling and functional on the normalized model — not to polish surfaces
|
||||
that Phase 8 replaces. Flag in each PR which edits are throwaway-pending-Wave-5 vs. durable.
|
||||
|
||||
**Wave 5 — Phase 8 UI (the original §8.1–§8.5).** Begins only after Waves 1–4 are merged and the
|
||||
normalized model is stable on `dev`. `CmsTrackGrid`, `CmsAlbumBrowser`, `CmsGenreBrowser`,
|
||||
`BatchEdit`, the mode toggle, and routing — all the UI work specced in §1–§13 below. The internal
|
||||
sequencing *within* Wave 5 is §13.
|
||||
|
||||
**Dependency summary:** `[1 + 2 together] → {3, 4 in parallel} → 5`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The CMS `/tracks` page today is a single `MudTable<TrackDto>` (the "Tracks" tab) plus a
|
||||
"Waveform Pre-Processing" tab, both inside one `MudTabs`. This phase keeps that page and adds
|
||||
**three browse modes** for the catalogue — **Track**, **Album**, **Genre** — selected by a
|
||||
toggle, each addressable by a distinct URL so the public home page can deep-link into a given
|
||||
mode.
|
||||
|
||||
**Pre-requisite gate:** all UI work in this phase sits on top of **§0 — the `TrackEntity`
|
||||
normalization** (split into `ReleaseEntity` + slimmed `TrackEntity`). §0 is a breaking data-model
|
||||
change that must land first; it also dissolves several of the open questions this spec originally
|
||||
carried (notably the `AlbumSummaryDto` widening question — the Release table supplies those fields
|
||||
directly). Read the UI sections below as written against the post-normalization schema.
|
||||
|
||||
The Waveform Pre-Processing tab is **removed** (§9): waveform-profile status becomes an in-grid
|
||||
status column on `CmsTrackGrid`, with per-row "Generate" actions and a page-level "Generate All
|
||||
Missing" button replacing the old tab.
|
||||
|
||||
The architectural spine is **one view-model feeding three renderings**. This matches Daniel's
|
||||
standing preference (memory: *One source, multiple views* — "same data, different uses and
|
||||
ergonomics"): the divergence lives in layout, not in data paths. All three modes read from the
|
||||
**same `ICmsTrackService.GetPagedAsync` path**; Album and Genre modes add summary calls
|
||||
(`GetAlbumSummariesAsync` / `GetGenreSummariesAsync`) for their *parent* rows but reuse the same
|
||||
paged-tracks call for their *detail* rows. No new API endpoint is introduced.
|
||||
|
||||
This phase also **supersedes the deferred §6.2** ("card-contextual filtering"). §6.2 was deferred
|
||||
precisely because filtering needed an intermediate browse surface (a list of albums, a list of
|
||||
genres) for the admin to pick from — "a CMS analogue of the public `AlbumsView`/`GenresView`."
|
||||
Album Mode and Genre Mode *are* that surface. §6.2 should be marked superseded-by-§8, not left
|
||||
dangling.
|
||||
|
||||
It also introduces one new page — **Batch Edit** (`/tracks/album/{albumName}/edit`) — reached
|
||||
from an Album-Mode row's Edit action. This is `BatchUpload.razor`'s master-detail mechanics with
|
||||
existing data preloaded and the submit path swapped from upload to metadata update.
|
||||
|
||||
### What this is NOT
|
||||
|
||||
- Not a public-site feature. This is CMS-only (`DeepDrftManager`, InteractiveServer). The public
|
||||
home page only *links in*; it renders nothing from this phase. **Caveat:** §0's normalization
|
||||
changes the shared `TrackDto` contract, which *does* touch the public client — see §0.3 consumer
|
||||
impact. The browse-mode UI is CMS-only; the data-model change is not.
|
||||
- **Is** a data-model change — §0 introduces `ReleaseEntity` and slims `TrackEntity`. (The browse
|
||||
*modes* introduce no new model; the pre-requisite does.)
|
||||
- Not a replacement for the existing single-track edit page (`/tracks/{id}`) — that survives,
|
||||
reached from Track Mode's per-row Edit. Batch Edit is a *second*, album-scoped editor.
|
||||
- The Waveform Pre-Processing tab **is removed** — its function moves into the grid (§9).
|
||||
|
||||
---
|
||||
|
||||
## 2. Grounding — what exists today (verified, not paraphrased)
|
||||
|
||||
Read against the live source on 2026-06-11. Two facts diverge from the brief and are
|
||||
load-bearing:
|
||||
|
||||
1. **`ICmsTrackService.GetPagedAsync` does NOT currently take album/genre filter parameters.**
|
||||
Its signature is `GetPagedAsync(int page, int pageSize, string? sortColumn, bool sortDescending, CancellationToken ct)`.
|
||||
The *underlying* `GET api/track/page` endpoint accepts `album=` / `genre=` query filters, and
|
||||
`CmsTrackService` (the impl) could pass them — but the interface does not expose them today.
|
||||
**This phase requires extending that signature** (see §6, data contracts). This is the one real
|
||||
contract change. Flag it as the first implementation prerequisite.
|
||||
|
||||
2. **An existing single-track edit page already lives at `/tracks/{id}`.** Track Mode's per-row
|
||||
Edit button (`Href="/tracks/{context.Id}"`) goes there. Album Mode's batch Edit is a *separate*
|
||||
page. Do not conflate them; do not route the album edit through `/tracks/{id}`.
|
||||
|
||||
Other verified facts:
|
||||
|
||||
- `/tracks` (`TrackList.razor`) is `@attribute [Authorize]`, InteractiveServer, two
|
||||
`MudTabPanel`s inside one `MudTabs`: "Tracks" (the `MudTable<TrackDto>` with `ServerData=LoadServerData`,
|
||||
`RowsPerPage=20`) and "Waveform Pre-Processing".
|
||||
- Current Track table columns: Track Name, Artist, Album, Genre, Release Date (`yyyy-MM-dd`),
|
||||
Entry Key (monospace caption), File Name (monospace caption), Actions (Edit→`/tracks/{id}`,
|
||||
Delete→confirm dialog → `DeleteTrackAsync`).
|
||||
- `BatchUpload.razor` (`/tracks/upload`): album-header fields (Album Name, Artist, Genre,
|
||||
Release Date as `YYYY-MM-DD` string, Release Type `MudSelect`, Cover Art `InputFile`), then a
|
||||
master list of `BatchTrackRow` (WavFile, TrackName, Status) with move-up/down/remove + a
|
||||
detail pane editing the selected row. Submit: optional one-shot cover-art upload
|
||||
(`UploadImageAsync`) → per-row `UploadTrackAsync` → per-row `UpdateAsync` to link the image
|
||||
(the upload endpoint takes no imagePath). `TrackNumber` is the 1-based list position (`i + 1`).
|
||||
- `ICmsTrackService` already has: `GetPagedAsync`, `GetByIdAsync`, `UploadTrackAsync`,
|
||||
`UpdateAsync(id, trackName, artist, album, genre, releaseDate, imagePath?, releaseType?, trackNumber?)`,
|
||||
`DeleteTrackAsync(id)`, `UploadImageAsync`, `GetAlbumSummariesAsync`, `GetGenreSummariesAsync`,
|
||||
`GetTrackCountAsync`, plus the waveform pair.
|
||||
- `TrackDto` fields: `Id, EntryKey, TrackName, Artist, Album?, Genre?, ReleaseDate? (DateOnly),
|
||||
ImagePath?, OriginalFileName?, ReleaseType, TrackNumber (int, 1-based)`.
|
||||
- `AlbumSummaryDto`: `Album (string), TrackCount (int), CoverImageKey (string?)`.
|
||||
- `GenreSummaryDto`: `Genre (string), TrackCount (int)`.
|
||||
- Image URL pattern (from public `TrackCard.razor`): if `ImagePath` non-empty,
|
||||
`background-image: url('api/image/{Uri.EscapeDataString(imagePath)}')`; else a CSS fallback
|
||||
swatch. Images served unauthenticated at `api/image/{entryKey}`.
|
||||
|
||||
---
|
||||
|
||||
## 3. URL scheme — RECOMMENDATION: route segments, not query params
|
||||
|
||||
**Recommend: `/tracks` (Track, default), `/tracks/albums`, `/tracks/genres`** as route segments,
|
||||
with `/tracks?mode=…` accepted as a tolerated alias if cheap.
|
||||
|
||||
Three options considered:
|
||||
|
||||
| Option | Form | Verdict |
|
||||
|---|---|---|
|
||||
| A. Query param | `/tracks?mode=albums` | Single `@page "/tracks"`, read `mode` from query. Simplest routing. |
|
||||
| B. Route segment | `/tracks/albums` | Multiple `@page` directives on one component, or sub-routes. Cleanest URL, best deep-link target. |
|
||||
| C. Separate pages | `/tracks`, `/albums`, `/genres` | Three components. Defeats the one-VM spine. Rejected. |
|
||||
|
||||
**Why B over A.** The brief's load-bearing requirement is that the *public home page hard-codes*
|
||||
these URLs. A hard-coded link wants to be a stable, clean, semantically-obvious path. A route
|
||||
segment (`/tracks/albums`) reads as a permanent address; a query param (`?mode=albums`) reads as
|
||||
transient view state. Route segments are also the convention already in this app — `/tracks`,
|
||||
`/tracks/upload`, `/tracks/{id}` are all segment-based, none use query params for navigation
|
||||
state. Matching that convention keeps the URL space coherent.
|
||||
|
||||
**Why tolerate A as an alias.** Blazor lets one component carry multiple `@page` directives.
|
||||
Adding `@page "/tracks"`, `@page "/tracks/albums"`, `@page "/tracks/genres"` to the one
|
||||
`TrackList` component (the toggle just sets which mode renders) costs nothing and lets the
|
||||
existing `?mode=` form (if any external link already uses it) keep working. If no such link
|
||||
exists, drop the alias — don't carry dead surface.
|
||||
|
||||
**Concrete routing:**
|
||||
|
||||
```razor
|
||||
@page "/tracks"
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/genres"
|
||||
```
|
||||
|
||||
The component reads which segment routed it (via `NavigationManager.Uri` parse, or three thin
|
||||
wrapper pages each setting an initial-mode parameter — see §10.1 for which). The toggle switches
|
||||
mode *and* pushes the corresponding URL (`NavigationManager.NavigateTo("/tracks/albums")`) so the
|
||||
address bar always reflects the current mode and deep-links round-trip.
|
||||
|
||||
**Decision needed (§10.1):** one multi-`@page` component reading the segment, vs. three trivial
|
||||
wrapper pages passing an `InitialMode` enum into a shared body component. Recommend the latter
|
||||
for clarity (each wrapper is two lines; the body owns the VM) — but it's a small call.
|
||||
|
||||
---
|
||||
|
||||
## 4. View-model design — `CmsTrackBrowserViewModel`
|
||||
|
||||
**Registered in DI as a scoped service and injected into the page** — matching the established
|
||||
project pattern (`TracksViewModel` in `DeepDrftPublic.Client` is registered scoped in
|
||||
`Startup.ConfigureDomainServices` and injected into `TracksView.razor`). The reason is dependency
|
||||
hygiene: backend service dependencies (`ICmsTrackService` and the waveform service) live on the VM,
|
||||
not in the component's `@inject` chain. The component injects only the VM; the VM holds the
|
||||
services. It owns mode state and the load logic for all three modes. The *views* are dumb; the VM is
|
||||
the single source of truth.
|
||||
|
||||
(This resolves the earlier open question that floated a page-owned `@code` object — see §12.5. The
|
||||
DI-scoped VM is the decided pattern, not the alternative.)
|
||||
|
||||
### 4a. State
|
||||
|
||||
```
|
||||
enum BrowseMode { Tracks, Albums, Genres }
|
||||
|
||||
class CmsTrackBrowserViewModel
|
||||
{
|
||||
BrowseMode Mode { get; private set; } // current mode (drives which view renders)
|
||||
|
||||
// Track mode: the MudTable owns its own ServerData paging; the VM only holds the
|
||||
// active filter (null for plain Track mode).
|
||||
string? ActiveAlbumFilter { get; private set; } // set by Album-mode child expansion? No —
|
||||
string? ActiveGenreFilter { get; private set; } // filters belong to CmsTrackGrid params, not VM state. See note.
|
||||
|
||||
// Album mode:
|
||||
IReadOnlyList<AlbumSummaryDto> Albums { get; } // parent rows, loaded once on entering Albums mode
|
||||
bool AlbumsLoading { get; }
|
||||
// child tracks are NOT held in VM; each expanded album row owns its own lazy GetPagedAsync(album:) call.
|
||||
|
||||
// Genre mode:
|
||||
IReadOnlyList<GenreSummaryDto> Genres { get; } // cards, loaded once on entering Genres mode
|
||||
bool GenresLoading { get; }
|
||||
string? ExpandedGenre { get; } // accordion: at most one expanded at a time
|
||||
}
|
||||
```
|
||||
|
||||
**Note on filters as VM state vs. component params.** There is a real design choice here. Two
|
||||
readings:
|
||||
|
||||
- **VM-as-truth (heavier):** the VM holds `ActiveGenreFilter`, and `CmsTrackGrid` reads it. Then
|
||||
the accordion's expansion is VM state.
|
||||
- **Param-as-truth (lighter, recommended):** `CmsTrackGrid` takes `AlbumFilter`/`GenreFilter` as
|
||||
`[Parameter]`s; the *view* passes the expanded genre/album down. The VM holds only `Mode`, the
|
||||
summary lists, and `ExpandedGenre` (which genre card is open). The grid filter is derived from
|
||||
`ExpandedGenre`, not stored separately.
|
||||
|
||||
**Recommend param-as-truth.** It keeps the VM small and keeps `CmsTrackGrid` genuinely reusable
|
||||
(its filter is an input, not a read of ambient VM state). The VM owns *mode + summaries + which
|
||||
thing is expanded*; the grid owns its own paging given a filter. This is the cleanest expression
|
||||
of "one source, multiple views" — the *source* is `GetPagedAsync`, and the grid is the one view
|
||||
component, parameterised.
|
||||
|
||||
### 4b. Which service calls happen where
|
||||
|
||||
| Trigger | Call | Owner |
|
||||
|---|---|---|
|
||||
| Enter Track mode | none up-front; `MudTable.ServerData` pages on demand | `CmsTrackGrid` |
|
||||
| Track-mode page/sort | `GetPagedAsync(page, size, sort, desc)` | `CmsTrackGrid.LoadServerData` |
|
||||
| Enter Album mode | `GetAlbumSummariesAsync()` once | VM |
|
||||
| Expand an album row | `GetPagedAsync(album: name, …)` lazily, first expansion only | the album row (cached after first load) |
|
||||
| Album row Edit | navigate to `/tracks/album/{name}/edit` | view |
|
||||
| Album row Delete | confirm → N× `DeleteTrackAsync` (the album's track ids) | VM/view |
|
||||
| Enter Genre mode | `GetGenreSummariesAsync()` once | VM |
|
||||
| Expand a genre card | `CmsTrackGrid` mounts with `GenreFilter=name` → `GetPagedAsync(genre: name, …)` | `CmsTrackGrid` |
|
||||
|
||||
State transitions: mode change → push URL → if entering Albums/Genres and the summary list is
|
||||
unloaded, fire the summary call; collapse any expanded child/genre. Re-entering a mode reuses the
|
||||
already-loaded summaries (cache for the page lifetime; a fresh page nav reloads).
|
||||
|
||||
---
|
||||
|
||||
## 5. Component tree
|
||||
|
||||
```
|
||||
TrackList.razor (@page "/tracks" + "/tracks/albums" + "/tracks/genres")
|
||||
│ owns: (injects) CmsTrackBrowserViewModel [DI-scoped], the mode toggle, page header
|
||||
│
|
||||
├── page header
|
||||
│ ├── mode toggle (Track | Album | Genre) — MudToggleGroup
|
||||
│ └── "Generate All Missing" button (Track mode only) ← §9
|
||||
│
|
||||
├── [Track mode] CmsTrackGrid (no filter) ← DEFAULT
|
||||
│ └── per-row: status icon + Generate (if no profile) + Edit/Delete/Info ← §9
|
||||
├── [Album mode] CmsAlbumBrowser (parent rows + lazy children)
|
||||
└── [Genre mode] CmsGenreBrowser (card grid + accordion → CmsTrackGrid)
|
||||
|
||||
(navigates to) BatchEdit.razor (/tracks/album/{albumName}/edit)
|
||||
```
|
||||
|
||||
The old `MudTabs` / "Waveform Pre-Processing" `MudTabPanel` is gone (§9). Whether a `MudTabs` shell
|
||||
survives at all depends on whether any other panel needs it — likely not.
|
||||
|
||||
New components:
|
||||
|
||||
### 5a. `CmsTrackGrid.razor` — the single reusable flat track table (THE DRY core)
|
||||
|
||||
The extracted Track-mode `MudTable<TrackDto>`. Single source of truth for the track-table layout.
|
||||
|
||||
| Parameter | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `AlbumFilter` | `string?` | `null` | When set, passed to `GetPagedAsync(album:)`. |
|
||||
| `GenreFilter` | `string?` | `null` | When set, passed to `GetPagedAsync(genre:)`. |
|
||||
| `ShowAddButton` | `bool` | `true` | The "Add Track" button. Off when embedded under a genre. |
|
||||
| `PageSize` | `int` | `20` | Mirrors today's `RowsPerPage`. |
|
||||
| `OnTracksChanged` | `EventCallback` | — | Raised after a delete, so a parent can refresh a count. |
|
||||
|
||||
Owns its own `MudTable` + `LoadServerData` + delete-confirm dialog (lifted verbatim from today's
|
||||
`TrackList`). When `AlbumFilter`/`GenreFilter` is set, `LoadServerData` passes it through. This is
|
||||
the component consumed by **both** Track mode (no filter) and Genre mode (`GenreFilter` set) — no
|
||||
duplicated table markup, exactly the brief's DRY requirement.
|
||||
|
||||
**Columns (new layout, §8 for detail):** Track # | Art (40×40) | Track Name | Artist | Album |
|
||||
Genre | Release Date (`d MMMM, yyyy`) | **Waveform Status** | Actions (Edit, Delete, per-row
|
||||
**Generate** when no profile, **Info** tooltip carrying Entry Key + File Name). Entry Key and File
|
||||
Name columns are *removed* from the grid and moved into the Info tooltip.
|
||||
|
||||
### 5b. `CmsAlbumBrowser.razor` — Album mode
|
||||
|
||||
Parent album rows (from `AlbumSummaryDto`) that expand to child track rows (lazy `GetPagedAsync`).
|
||||
See §6 for the data flow. Uses an **expandable `MudTable` with a `RowTemplate` + per-row child
|
||||
content**, not `MudTreeView` (see §10.2 recommendation). Each parent row: art thumb, album name,
|
||||
artist (derived), track count, genre (derived), earliest release date, release-type chip, Edit +
|
||||
Delete actions. Child rows: track # + track name only (a minimal inline template, not a full
|
||||
`CmsTrackGrid` — the brief is explicit that album children are simpler).
|
||||
|
||||
### 5c. `CmsGenreBrowser.razor` — Genre mode
|
||||
|
||||
Responsive `MudCard` grid, one card per `GenreSummaryDto` (genre name + track count). Accordion:
|
||||
clicking a card expands it (and collapses any other) to reveal a `CmsTrackGrid` with
|
||||
`GenreFilter=genre, ShowAddButton=false` beneath/within. See §7.
|
||||
|
||||
### 5d. `BatchEdit.razor` — new page (§ batch edit below)
|
||||
|
||||
Reached from `CmsAlbumBrowser` row Edit. See dedicated section.
|
||||
|
||||
---
|
||||
|
||||
## 6. Album mode data flow — parent eager, children lazy
|
||||
|
||||
**Parent rows (eager):** on entering Album mode, the VM calls `GetAlbumSummariesAsync()` once.
|
||||
Each `AlbumSummaryDto` renders one parent row. The cover thumb uses `CoverImageKey` with the same
|
||||
`api/image/{Uri.EscapeDataString(key)}` fallback pattern.
|
||||
|
||||
**Derived parent-row fields.** `AlbumSummaryDto` gives only `Album`, `TrackCount`, `CoverImageKey`.
|
||||
The brief asks the parent row to also show artist, genre, earliest release date, and a
|
||||
release-type chip — **none of which are on the summary DTO.** Two ways to get them:
|
||||
|
||||
- **(i) Derive from children on expand only.** Parent row shows album name, track count, cover at
|
||||
rest; artist/genre/date/type fill in *after* the row is expanded and the children load. Cheap,
|
||||
but the parent row is informationally thin until expanded.
|
||||
- **(ii) Widen `AlbumSummaryDto`.** Add `Artist?` (or "Various"), `Genre?` (or null→"—"),
|
||||
`EarliestReleaseDate?`, `ReleaseType?` computed server-side in the albums-summary query. The
|
||||
parent row is fully populated without expansion.
|
||||
|
||||
**Recommend (ii) — widen the summary DTO.** The parent row is meant to be a scannable album
|
||||
catalogue; showing artist/genre/date there is the point of Album mode, and lazy-filling them on
|
||||
expand makes the resting view feel broken. Computing them in the existing albums-summary SQL is a
|
||||
modest server change (a `GROUP BY album` with `MIN(release_date)`, a uniform-artist check, etc.),
|
||||
and it keeps the parent render synchronous. **This is a data-contract change to flag (§ open
|
||||
questions / §10.3).** If Daniel would rather not touch the DTO, fall back to (i) and accept the
|
||||
thin resting row.
|
||||
|
||||
**"Various" / "—" derivation rules** (wherever they're computed):
|
||||
|
||||
- Artist: if all tracks in the album share one artist → that artist; else `"Various"`.
|
||||
- Genre: if uniform across the album → that genre; else `"—"`.
|
||||
- Release date: earliest (`MIN`) track `ReleaseDate` in the album.
|
||||
- Release-type chip: the brief says "infer from TrackCount + ReleaseType, or from first track."
|
||||
Simplest defensible rule: use the album's tracks' `ReleaseType` if uniform; else fall back to
|
||||
`TrackCount` heuristic (1 = Single, 2–5 = EP, 6+ = LP). Recommend: trust the stored
|
||||
`ReleaseType` of the first track (it's set at upload for the whole release) and only use the
|
||||
count heuristic if `ReleaseType` is absent. Flag as a small decision (§10.4).
|
||||
|
||||
**Child rows (lazy).** On first expansion of an album row, call
|
||||
`GetPagedAsync(album: albumName, page: 1, pageSize: <large enough for one album>, sort: "TrackNumber")`.
|
||||
Albums are small (a release is a handful of tracks), so a single page suffices — pick a page size
|
||||
that comfortably exceeds any real album (e.g. 100) rather than paging within an album. Cache the
|
||||
result on the row so re-expanding doesn't re-fetch. Child rows render **track number + track
|
||||
name only** — no per-track Edit/Delete (the brief: editing is via album-level Batch Edit).
|
||||
|
||||
**This reuses the same `GetPagedAsync` path as every other mode** — no new endpoint, honouring the
|
||||
one-source spine. The only new seam is the lazy-on-expand trigger.
|
||||
|
||||
---
|
||||
|
||||
## 7. Genre mode accordion
|
||||
|
||||
- `GetGenreSummariesAsync()` once on entering Genre mode → a responsive `MudCard` grid
|
||||
(`MudItem xs=12 sm=6 md=4` or similar), one card per genre: genre name (`Typo.h6`), track count
|
||||
(`Typo.body2`), and a fallback visual (genres have no cover art — use a neutral swatch or a
|
||||
genre glyph).
|
||||
- **Accordion, one open at a time.** Clicking a card sets `VM.ExpandedGenre = genre` (clicking the
|
||||
open card again clears it). When a genre is expanded, render a `CmsTrackGrid` with
|
||||
`GenreFilter=genre, ShowAddButton=false` **below the card grid** (a full-width panel under the
|
||||
expanded card's row), not inside the card (a track table doesn't fit a card cell). The
|
||||
expanded-card visual state (elevation/border) signals which genre's tracks are showing.
|
||||
- Layout pattern borrowed from a **master-detail / expanding-panel accordion** (cf. macOS Finder
|
||||
column expand, or MudBlazor's own `MudExpansionPanels` — though here the panel hosts a grid, so a
|
||||
manual expand region under the card row reads better than nesting a table inside
|
||||
`MudExpansionPanel`). Recommend a manual `@if (VM.ExpandedGenre == genre)` panel rendered after
|
||||
the card grid, keyed to the expanded genre.
|
||||
- The grid inside is the *same* `CmsTrackGrid` as Track mode — DRY satisfied: zero duplicated table
|
||||
markup, the genre filter is just a parameter.
|
||||
|
||||
---
|
||||
|
||||
## 8. Track-mode grid changes (the `CmsTrackGrid` layout)
|
||||
|
||||
Applied in `CmsTrackGrid` (so Genre mode's embedded grid gets them for free):
|
||||
|
||||
1. **Column order (left to right):** Track # → Art thumb → Track Name → Artist → Album → Genre →
|
||||
Release Date → **Waveform Status** → Actions. (Track # is the very first column; thumb second;
|
||||
per the brief. Waveform Status sits just before Actions — see item 7 and §9.)
|
||||
2. **Track # column:** plain `@context.TrackNumber`. Header sortable on `TrackNumber` is optional
|
||||
(default sort stays Track Name asc to match today).
|
||||
3. **Art thumb (40×40):** reuse the public `TrackCard` fallback pattern.
|
||||
- If `ImagePath` non-empty: a `div` 40×40 with
|
||||
`background-image: url('api/image/@(Uri.EscapeDataString(context.ImagePath))'); background-size: cover; border-radius: 4px;`.
|
||||
- Else: a neutral fallback swatch (mirror the public `deepdrft-track-row-thumb--fallback`
|
||||
class — a CMS-side equivalent in `CmsTrackGrid.razor.css`). Do **not** depend on the public
|
||||
client's CSS; define the CMS fallback locally (the public class lives in `DeepDrftPublic.Client`,
|
||||
a different host). If the thumb/fallback is genuinely identical to the public one, that's a
|
||||
candidate for a shared `DeepDrftShared.Client` component later — flag, don't force now (§10.6).
|
||||
4. **Release Date format:** display `@context.ReleaseDate?.ToString("d MMMM, yyyy")` (→ "9 June,
|
||||
2026"); `"—"` when null. Sorting is unchanged — the sort key is the raw `DateOnly`
|
||||
(`SortLabel="ReleaseDate"` still maps to the date column server-side); the format is
|
||||
presentation-only and does not touch the sort.
|
||||
5. **Entry Key + File Name → Info tooltip.** Remove both columns. Add an **Info icon** in the
|
||||
Actions cell (`Icons.Material.Filled.InfoOutlined`, `Size.Small`) wrapped in a `MudTooltip`
|
||||
whose content shows both values monospaced:
|
||||
```razor
|
||||
<MudTooltip>
|
||||
<TooltipContent>
|
||||
<div style="font-family: monospace; text-align: left;">
|
||||
<div>Entry: @context.EntryKey</div>
|
||||
<div>File: @(context.OriginalFileName ?? "—")</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
<ChildContent>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.InfoOutlined" Size="Size.Small" />
|
||||
</ChildContent>
|
||||
</MudTooltip>
|
||||
```
|
||||
(Brief offered row-level tooltip *or* an info icon; recommend the **info icon in Actions** —
|
||||
a row-wide tooltip fights the existing per-cell `DataLabel` hovers and is awkward to trigger
|
||||
precisely. The icon is an explicit, discoverable affordance.)
|
||||
6. **Unchanged:** sort labels, paging (`PageSizeOptions { 10, 20, 50 }`), Add Track button (gated
|
||||
by `ShowAddButton`), Edit (→`/tracks/{id}`) and Delete (confirm dialog) actions.
|
||||
7. **Waveform Status column (new — replaces the Waveform tab, see §9).** A status icon showing
|
||||
whether a waveform profile exists for the track: `Icons.Material.Filled.CheckCircle` colored
|
||||
`Color.Success` when present, `Icons.Material.Filled.Cancel` colored `Color.Warning` when absent.
|
||||
This maps to the existing `HasProfile` boolean on `WaveformStatusDto`. **Data-contract decision
|
||||
(flagged):** the grid binds `TrackDto`, which has no waveform status today. Two options —
|
||||
(a) add `HasWaveformProfile` (bool) to `TrackDto`, or (b) the grid does a separate waveform-status
|
||||
lookup and merges by `EntryKey`. **Recommend (a)** — one field, keeps the grid a single data
|
||||
source rather than fanning out a second call per page. When `HasWaveformProfile` is false, the
|
||||
Actions cell also shows a per-row **Generate** action button (only rendered when the profile is
|
||||
missing). See §9.
|
||||
|
||||
## 9. Waveform processing — tab removed, folded into the grid
|
||||
|
||||
**Decision (review, 2026-06-11): the "Waveform Pre-Processing" `MudTabPanel` is removed entirely.**
|
||||
Its three responsibilities relocate:
|
||||
|
||||
1. **Per-track status** → the **Waveform Status column** in `CmsTrackGrid` (§8 item 7). A
|
||||
success/warning icon driven by the profile-exists boolean. Every track's status is visible inline
|
||||
while browsing, instead of behind a separate tab.
|
||||
2. **Per-track generate** → a per-row **Generate** action in `CmsTrackGrid`, rendered only when the
|
||||
track has no profile (`!HasWaveformProfile`). Generating refreshes the row's status.
|
||||
3. **Bulk generate** → a **"Generate All Missing"** button in the **main tracks-page header area**,
|
||||
visible when the user is in **Track mode**. This replaces the bulk-run button that lived in the
|
||||
Waveform tab.
|
||||
|
||||
With the tab gone, the `MudTabs` shell may collapse to a single surface (the mode toggle + the
|
||||
rendered mode + the page header). Confirm whether `MudTabs` is retained for any other panel or
|
||||
removed outright. The mode toggle and the "Generate All Missing" button both live in the Track-mode
|
||||
page header.
|
||||
|
||||
Because the status + generate affordances live in `CmsTrackGrid`, **Genre mode's embedded grid gets
|
||||
them for free** — a genre's tracks show waveform status and per-row generate identically to Track
|
||||
mode. The "Generate All Missing" page-level button is Track-mode-scoped (it operates over the whole
|
||||
catalogue, not a filtered slice); whether it should also appear scoped-to-filter in Genre/Album mode
|
||||
is a minor follow-up, not required for this phase.
|
||||
|
||||
This depends on the §8 item-7 data-contract decision (recommend `HasWaveformProfile` on `TrackDto`)
|
||||
so the grid has the status without a second per-page lookup — which dovetails cleanly with §0's
|
||||
`TrackDto` rework (the field can be added as part of the normalization DTO pass).
|
||||
|
||||
---
|
||||
|
||||
## 10. Batch Edit page
|
||||
|
||||
`@page "/tracks/album/{AlbumName}/edit"` (URL-encoded album name as the route param).
|
||||
|
||||
**Component boundary — CONFIRMED (review, 2026-06-11): a new `BatchEdit.razor` page that shares
|
||||
extracted sub-components with `BatchUpload.razor`, NOT a `BatchUpload` grown with an `isEdit`
|
||||
flag.** Sub-components to extract: the album-header fields block (post-§0, edits the `ReleaseDto`),
|
||||
the batch track list (master list with move-up/down/remove + status chips), and the track detail
|
||||
pane. Both pages compose these with their own submit logic.
|
||||
|
||||
Why not an `isEdit` parameter on `BatchUpload`:
|
||||
|
||||
- The two pages diverge in *enough* places that a flag breeds conditionals: edit preloads header +
|
||||
rows; edit's track rows have no WAV slot for *existing* tracks but *do* allow adding new ones;
|
||||
edit's submit is per-row `UpdateAsync` (existing) + `UploadTrackAsync` (newly added), where
|
||||
upload is all `UploadTrackAsync`. A single component with `@if (isEdit)` scattered through the
|
||||
master list, the detail pane, and `SubmitAsync` becomes hard to read and easy to break.
|
||||
- But the *mechanics* are highly shareable: the album-header field block, the master list (rows
|
||||
with move-up/down/remove + status chip), the detail pane, the cover-art picker, `FormatBytes`,
|
||||
the row model. Those should be **extracted into shared sub-components / a shared partial** that
|
||||
both `BatchUpload` and `BatchEdit` compose.
|
||||
|
||||
So: extract the reusable pieces (e.g. `AlbumHeaderFields.razor`, `BatchTrackList.razor`,
|
||||
`BatchTrackDetail.razor`), and have both pages compose them with their own submit logic. This is
|
||||
the same DRY-by-extraction move as `CmsTrackGrid`, applied to the editor. **Trade-off:** more files
|
||||
and an upfront extraction cost on `BatchUpload` (which works today). If Daniel wants the smaller
|
||||
diff, the `isEdit`-flag route is faster to ship but accrues the conditional-soup debt — call it
|
||||
out, recommend extraction.
|
||||
|
||||
**Preload (edit mode):**
|
||||
|
||||
- Load the album's tracks via `GetPagedAsync(album: albumName, sort: "TrackNumber")` (same lazy
|
||||
call Album mode uses).
|
||||
- Header fields pre-filled from the first track: Album, Artist, Genre, Release Date, ReleaseType,
|
||||
and cover art (`ImagePath` → preview via `api/image/{key}`).
|
||||
- Master list shows existing tracks ordered by `TrackNumber`, each `TrackName` pre-filled, each
|
||||
flagged **existing** (carries its `Id`, `EntryKey`; no WAV slot, shows current file name read-only).
|
||||
- Newly added rows (via the same WAV `InputFile`) are flagged **new** and carry a WAV file like
|
||||
today.
|
||||
|
||||
**Row model (extended):**
|
||||
|
||||
```
|
||||
class BatchEditRow {
|
||||
long? Id; // null = new track (upload), set = existing (update)
|
||||
string? EntryKey; // existing only
|
||||
string? OriginalFileName; // existing only, read-only display
|
||||
IBrowserFile? WavFile; // new only
|
||||
string TrackName;
|
||||
int TrackNumber; // 1-based, reorderable
|
||||
Status; ErrorMessage;
|
||||
}
|
||||
```
|
||||
|
||||
**Submit (edit mode):** for each row in order, `TrackNumber = position`:
|
||||
|
||||
- **Existing row (`Id` set):** `UpdateAsync(Id, TrackName, Artist, Album, Genre, ReleaseDate,
|
||||
imagePath, ReleaseType, TrackNumber)`. Note `imagePath` is **tri-state** in `UpdateAsync` (null =
|
||||
unchanged, "" = clear, value = set) — pass the (possibly newly uploaded) cover key to every row
|
||||
so the whole album shares one cover, or `null` to leave each unchanged if cover wasn't touched.
|
||||
- **New row (`Id` null):** `UploadTrackAsync(...)` then the cover-link `UpdateAsync` — exactly
|
||||
today's `BatchUpload` path.
|
||||
- If a new cover image was picked, upload it once first (`UploadImageAsync`), then apply its key
|
||||
across all rows' `UpdateAsync`. Mirrors the existing one-shot-image pattern.
|
||||
|
||||
**Reorder + delete within the album** are natural extensions (the master list already has
|
||||
move-up/down/remove). Removing an *existing* row should delete the track (`DeleteTrackAsync`) —
|
||||
flag whether remove-in-edit deletes or just detaches (§10.8); recommend an explicit confirm before
|
||||
deleting an existing track from within the editor.
|
||||
|
||||
**Album-row Delete (from `CmsAlbumBrowser`, not the editor):** confirm dialog scoped to the album
|
||||
("Delete all N tracks in '{album}'? This removes metadata and audio for every track."), then
|
||||
`DeleteTrackAsync` for each track id in the album. The brief specifies album-scoped delete; make
|
||||
the confirm copy unambiguous about the blast radius.
|
||||
|
||||
---
|
||||
|
||||
## 11. Data contracts — summary of what changes
|
||||
|
||||
| Contract | Change | Why | Risk |
|
||||
|---|---|---|---|
|
||||
| **`TrackEntity` / `ReleaseEntity`** (§0) | **Split** flat `TrackEntity` into `ReleaseEntity` + slim `TrackEntity` | normalization; pre-requisite gate | **High — breaking migration; see §0** |
|
||||
| **`TrackDto`** (§0) | Slim down (drop release flat fields, add `ReleaseId` + **nested `Release` (`ReleaseDto`)**) | follows the entity split; nested not flat (open Q3 resolved 2026-06-11) | High — touches public + CMS consumers; all updated in Waves 3 + 4 (§0.3, §0.6) |
|
||||
| **`ReleaseDto`** (§0) | **New**, mirrors `ReleaseEntity`; retires `AlbumSummaryDto` | Album mode reads the Release table directly; also the nested read object on `TrackDto` | Medium |
|
||||
| `ICmsTrackService.GetPagedAsync` | **Add** `string? album = null, string? genre = null` params (or an overload) | All three modes' filtered reads route through here; endpoint already supports the query filters. Post-§0 the filter joins through `releases` | Low — additive, default-null keeps callers compiling |
|
||||
| `CmsTrackService` (impl) | Pass `album`/`genre` through to the `api/track/page` query string | wire the above | Low |
|
||||
| `TrackDto.HasWaveformProfile` (bool) | **Add** (recommended over a second lookup, §8 item 7) | in-grid waveform status column without per-page fan-out | Low — additive; fold into the §0 DTO pass |
|
||||
| `AlbumSummaryDto` | **Dissolved by §0** — replaced by `ReleaseDto`; `GetAlbumSummariesAsync` → `GetReleasesAsync`. (The old "widen the DTO" question is moot — the Release table has all the fields.) | normalization | — (replaced, not widened) |
|
||||
| New row models (`BatchEditRow`) | new, CMS-internal | edit mode | None (internal) |
|
||||
| Routes | `/tracks/albums`, `/tracks/genres`, `/tracks/album/{albumName}/edit` | mode deep-links + batch edit | Low |
|
||||
|
||||
No new API endpoints (existing endpoints' *shapes* change per §0). The data-model change **does**
|
||||
reach the public client (§0.3) — the browse-mode UI does not, but the `TrackDto` rework does.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions (need Daniel before build)
|
||||
|
||||
1. **(§3) Routing mechanism.** Three wrapper pages passing `InitialMode`, vs. one multi-`@page`
|
||||
component parsing the segment. Recommend wrapper pages. *Small call.*
|
||||
2. **(§5b/§10.2) Album expand widget.** Recommend an expandable `MudTable` (parent `RowTemplate`
|
||||
+ a child-row region toggled per row) over `MudTreeView` — the parent rows are tabular
|
||||
(multi-column: thumb, name, artist, count, genre, date, type, actions), which a tree node
|
||||
renders poorly. `MudTreeView` suits hierarchies of like items, not parent-rows-of-different-shape.
|
||||
Confirm.
|
||||
3. **~~(§6/§11) Widen `AlbumSummaryDto`?~~ — DISSOLVED by §0.** The normalization gives Album mode a
|
||||
real `ReleaseEntity`/`ReleaseDto` with artist/genre/date/type on it directly; there is no summary
|
||||
DTO to widen and no derivation from children. The question is moot once §0 lands.
|
||||
4. **(§6) Release-type chip derivation.** *Mostly dissolved by §0* — `ReleaseType` now lives on the
|
||||
Release record, so the chip reads `Release.ReleaseType` directly. The count heuristic is only a
|
||||
migration-time concern (deriving `ReleaseType` for legacy releases that had it per-track); flag in
|
||||
the §0 migration, not the UI.
|
||||
5. **~~(§4/§10.5) VM scoping~~ — RESOLVED.** The VM is **DI-registered as a scoped service** and
|
||||
injected, matching the `TracksViewModel` pattern in `DeepDrftPublic.Client` (keeps backend service
|
||||
deps off the component's inject chain). Not page-owned. See §4.
|
||||
6. **(§8) Shared thumb component.** The 40×40 art+fallback thumb is near-identical to the public
|
||||
`TrackCard` thumb but in a different host. Define locally in `CmsTrackGrid` now; flag a future
|
||||
`DeepDrftShared.Client` extraction (Phase 7 territory) rather than coupling the CMS to the
|
||||
public client's CSS. Confirm "local now, share later."
|
||||
7. **~~(§9) Mode URL vs. tab selection~~ — DISSOLVED.** The Waveform tab is removed (§9), so there
|
||||
is no tab/mode independence question left. `/tracks/albums` simply lands the page in Album mode.
|
||||
If a `MudTabs` shell survives at all, confirm what (if anything) the second panel is — likely
|
||||
none.
|
||||
8. **(§10) Remove-in-edit semantics.** Does removing an *existing* track row in Batch Edit delete
|
||||
the track (with confirm), or only detach it from the editing session? Recommend delete-with-
|
||||
confirm (a release editor that can't drop a track is half a tool), but it's a destructive
|
||||
action worth Daniel's explicit nod.
|
||||
9. **~~Batch Edit component boundary (§10)~~ — RESOLVED.** Confirmed: a **new `BatchEdit.razor`
|
||||
page** sharing **extracted sub-components** with `BatchUpload.razor` — *not* an `isEdit` flag on
|
||||
`BatchUpload`. Sub-components to extract: the **album-header fields block**, the **batch track
|
||||
list** (master list with move-up/down/remove + status chips), and the **track detail pane**. Both
|
||||
`BatchUpload` and `BatchEdit` compose these with their own submit logic. (Post-§0, the
|
||||
"album-header block" edits the `ReleaseDto`.) See §10.
|
||||
10. **Home-page links.** Out of scope for this CMS phase, but: the public home page will hard-code
|
||||
`/tracks`, `/tracks/albums`, `/tracks/genres` (cross-host links into the Manager app). Confirm
|
||||
the Manager base URL is stable/known to the public site at build time (the URL *paths* are
|
||||
settled here; the *host* resolution is a public-site config question, not this phase's).
|
||||
|
||||
---
|
||||
|
||||
## 13. Suggested sequencing (within the phase)
|
||||
|
||||
1. Extend `GetPagedAsync` with album/genre filters (unblocks everything).
|
||||
2. Extract `CmsTrackGrid` from today's `TrackList` table + apply the §8 layout changes (Track
|
||||
mode reaches parity — shippable on its own).
|
||||
3. Add the mode toggle + routing + `CmsGenreBrowser` (Genre mode reuses `CmsTrackGrid` — cheapest
|
||||
second mode).
|
||||
4. `CmsAlbumBrowser` + the `AlbumSummaryDto` widening decision (§6) (the heaviest mode).
|
||||
5. Extract `BatchUpload` sub-components + build `BatchEdit` (depends on Album mode's Edit action).
|
||||
|
||||
Steps 1–2 deliver visible value (cleaner Track grid) before any mode work lands, and each step is
|
||||
independently mergeable.
|
||||