CMS Phase 9 Wave 3: Release Archive tab, medium selector, Session/Mix browsers

Renames Genre tab to Release Archive with switch-free medium card group
(Enum.GetValues-driven). Adds MediumFields single dispatch + CutFields/SessionFields/
MixFields per-medium sections embedded by all five upload/edit forms. BatchUpload
enforces single-track invariant for Session/Mix. Adds CmsSessionBrowser (hero-image
upload) and CmsMixBrowser (waveform status + per-row Generate trigger).
ICmsReleaseService/CmsReleaseService wraps api/release endpoints.
Note: medium selector is forward-compat only — API write path pending.
This commit is contained in:
daniel-c-harvey
2026-06-12 23:07:15 -04:00
parent 5f7eaed112
commit 2f47efeb46
22 changed files with 970 additions and 45 deletions
@@ -22,15 +22,6 @@
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">
@@ -70,6 +61,12 @@
</MudField>
</MudItem>
</MudGrid>
<MudDivider Class="my-4" />
<MediumFields @bind-Medium="MediumBinding"
@bind-ReleaseType="ReleaseTypeBinding"
Disabled="Disabled" />
</MudPaper>
@code {
@@ -83,6 +80,8 @@
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
@@ -98,6 +97,20 @@
? null
: $"/api/image/{Uri.EscapeDataString(ExistingImagePath)}";
// MediumFields uses two-way @bind; bridge its bindings to this component's own
// parameter/EventCallback pairs so the parent form stays the single owner of the values.
private ReleaseMedium MediumBinding
{
get => Medium;
set => MediumChanged.InvokeAsync(value);
}
private ReleaseType ReleaseTypeBinding
{
get => ReleaseType;
set => ReleaseTypeChanged.InvokeAsync(value);
}
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
SelectedImageFileChanged.InvokeAsync(e.File);
@@ -34,6 +34,8 @@
@bind-Genre="_genre"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile"
ExistingImagePath="_existingImagePath"
Disabled="_saving" />
@@ -53,6 +55,9 @@
<MudGrid>
<MudItem xs="12" md="5">
@* TODO: When medium write path lands, collapse to single-track slot here for Session/Mix
(matching BatchUpload's @if (_medium == ReleaseMedium.Cut) guard). Until then,
BatchEdit's track list is unrestricted because _medium is read-only on the edit form. *@
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_saving"
@@ -124,6 +129,12 @@
private string _genre = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// The medium selector drives ReleaseType visibility. NOTE: the metadata update endpoint
// (PUT api/track/meta) has no medium field, so a medium change is not persisted on save today —
// the selector reflects/adjusts local form shape only. Persisting medium-on-edit is an API change.
private void OnMediumChanged(ReleaseMedium medium) => _medium = medium;
protected override async Task OnInitializedAsync()
{
@@ -155,6 +166,7 @@
_genre = release?.Genre ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
_medium = release?.Medium ?? ReleaseMedium.Cut;
_existingImagePath = release?.ImagePath;
_tracks = tracks.Select(t => new BatchRowModel
@@ -6,6 +6,7 @@
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -22,28 +23,51 @@
@bind-Genre="_genre"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@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>
@if (_medium == ReleaseMedium.Cut)
{
<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>
<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>
}
else
{
@* Session/Mix are single-track releases — no multi-track master list. A single WAV slot. *@
<MudPaper Class="pa-4" Elevation="2">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle1">Track</MudText>
<InputFile OnChange="HandleSingleWavSelected" accept=".wav,audio/wav,audio/x-wav" disabled="@_uploading" />
@if (_tracks.Count > 0)
{
<MudTextField @bind-Value="_tracks[0].TrackName"
Label="Track Name"
Variant="Variant.Outlined"
Disabled="_uploading" />
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
}
</MudStack>
</MudPaper>
}
@if (!string.IsNullOrEmpty(_errorMessage))
{
@@ -92,6 +116,39 @@
private string _genre = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// Switching to a single-track medium (Session/Mix) collapses any multi-track selection to the
// first row so the single-track invariant holds before submit. Switching back to Cut keeps it.
private void OnMediumChanged(ReleaseMedium medium)
{
_medium = medium;
if (medium != ReleaseMedium.Cut && _tracks.Count > 1)
{
_tracks.RemoveRange(1, _tracks.Count - 1);
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
}
}
// Single-track WAV picker for Session/Mix: replaces the one row rather than appending.
private void HandleSingleWavSelected(InputFileChangeEventArgs e)
{
_errorMessage = null;
var file = e.File;
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
return;
}
_tracks.Clear();
_tracks.Add(new BatchRowModel
{
WavFile = file,
TrackName = Path.GetFileNameWithoutExtension(file.Name)
});
_selectedIndex = 0;
}
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{
@@ -237,7 +294,8 @@
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber);
trackNumber,
_medium);
if (!result.Success || result.Value is null)
{
@@ -272,6 +330,21 @@
}
}
// Mix uploads fire the server-side high-res waveform trigger (§3.4). The CMS
// computes nothing — the API derives the datum from the audio it just stored.
// Non-blocking: the track is persisted; a failed trigger is recoverable from
// the Mixes browser's per-row Generate action.
if (_medium == ReleaseMedium.Mix && result.Value.ReleaseId is { } mixReleaseId)
{
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
if (!waveformResult.Success)
{
Logger.LogWarning("Batch upload: mix waveform trigger failed for release {ReleaseId} ('{TrackName}')",
mixReleaseId, row.TrackName);
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
}
}
row.Status = BatchRowStatus.Done;
succeeded++;
}
@@ -0,0 +1,165 @@
@page "/tracks/mixes"
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@attribute [Authorize]
@inject ICmsReleaseService CmsReleaseService
@inject ISnackbar Snackbar
@inject ILogger<CmsMixBrowser> Logger
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Class="mb-4">
Back to Release Archive
</MudButton>
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (_rows.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">No mixes found.</MudText>
}
else
{
<MudTable T="MixRow" Items="_rows" Hover="true" Striped="true" Dense="true" Bordered="false" FixedHeader="true">
<HeaderContent>
<MudTh Style="width: 1%;">Cover</MudTh>
<MudTh>Mix</MudTh>
<MudTh>Artist</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Cover">
@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="Mix">@context.Release.Title</MudTd>
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
<MudTd DataLabel="Waveform">
@if (context.HasWaveform)
{
<MudTooltip Text="Waveform generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudTooltip Text="No waveform — incomplete">
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
</MudTooltip>
}
</MudTd>
<MudTd DataLabel="Actions">
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.GraphicEq"
Disabled="@context.IsGenerating"
OnClick="@(() => GenerateWaveformAsync(context))">
@if (context.IsGenerating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
}
else
{
<span>@(context.HasWaveform ? "Regenerate" : "Generate")</span>
}
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudContainer>
@code {
private List<MixRow> _rows = new();
private bool _loading = true;
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
_loading = true;
// Mixes are single-track releases; a single generous page covers the CMS catalogue.
var result = await CmsReleaseService.GetPagedAsync(
ReleaseMedium.Mix, page: 1, pageSize: 100,
sortColumn: "Title", sortDescending: false);
if (result.Success && result.Value is not null)
{
_rows = result.Value.Items
.Select(r => new MixRow
{
Release = r,
HasWaveform = !string.IsNullOrEmpty(r.MixMetadata?.WaveformEntryKey)
})
.ToList();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load mixes: {error}", Severity.Error);
_rows = new List<MixRow>();
}
_loading = false;
}
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string entryKey) =>
$"/api/image/{Uri.EscapeDataString(entryKey)}";
private async Task GenerateWaveformAsync(MixRow row)
{
row.IsGenerating = true;
StateHasChanged();
try
{
var result = await CmsReleaseService.GenerateMixWaveformAsync(row.Release.Id);
if (result.Success)
{
// Optimistic update: the trigger succeeded, so the waveform is stored. Unlike SessionBrowser's
// re-fetch (which retrieves the server-generated HeroImageEntryKey), there is nothing to reflect
// back here — HasWaveform is derived from WaveformEntryKey being non-null, which we know is now set.
row.HasWaveform = true;
Snackbar.Add($"Generated waveform for '{row.Release.Title}'.", Severity.Success);
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Waveform generation failed for '{row.Release.Title}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for release {ReleaseId}", row.Release.Id);
Snackbar.Add($"Waveform generation failed for '{row.Release.Title}' — please try again.", Severity.Error);
}
finally
{
row.IsGenerating = false;
StateHasChanged();
}
}
private sealed class MixRow
{
public required ReleaseDto Release { get; set; }
public bool HasWaveform { get; set; }
public bool IsGenerating { get; set; }
}
}
@@ -0,0 +1,15 @@
/* Scoped duplicate of the album-browser thumb idiom. Blazor CSS isolation is per-component, so the
class defined in CmsAlbumBrowser.razor.css does not reach this component's markup — a small,
intentional duplication rather than promoting a two-rule block to global app.css. */
.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,178 @@
@page "/tracks/sessions"
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsReleaseService CmsReleaseService
@inject ISnackbar Snackbar
@inject ILogger<CmsSessionBrowser> Logger
@inject NavigationManager Navigation
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Class="mb-4">
Back to Release Archive
</MudButton>
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (_rows.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">No sessions found.</MudText>
}
else
{
<MudTable T="SessionRow" Items="_rows" Hover="true" Striped="true" Dense="true" Bordered="false" FixedHeader="true">
<HeaderContent>
<MudTh Style="width: 1%;">Cover</MudTh>
<MudTh Style="width: 1%;">Hero</MudTh>
<MudTh>Session</MudTh>
<MudTh>Artist</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Cover">
@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="Hero">
@if (context.HeroImageEntryKey is { Length: > 0 } heroKey)
{
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
}
else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
</MudTd>
<MudTd DataLabel="Session">@context.Release.Title</MudTd>
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
<MudTd DataLabel="Actions">
<MudFileUpload T="IBrowserFile"
Accept="image/*"
FilesChanged="@(file => UploadHeroAsync(context, file))"
Disabled="@context.IsUploading">
<ActivatorContent>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Image"
Disabled="@context.IsUploading">
@if (context.IsUploading)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Uploading…</span>
}
else
{
<span>@(context.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
}
</MudButton>
</ActivatorContent>
</MudFileUpload>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudContainer>
@code {
private List<SessionRow> _rows = new();
private bool _loading = true;
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
_loading = true;
// Sessions are single-track releases; a single generous page covers the CMS catalogue (same
// small-catalogue assumption the album browser makes).
var result = await CmsReleaseService.GetPagedAsync(
ReleaseMedium.Session, page: 1, pageSize: 100,
sortColumn: "Title", sortDescending: false);
if (result.Success && result.Value is not null)
{
_rows = result.Value.Items
.Select(r => new SessionRow
{
Release = r,
HeroImageEntryKey = r.SessionMetadata?.HeroImageEntryKey
})
.ToList();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load sessions: {error}", Severity.Error);
_rows = new List<SessionRow>();
}
_loading = false;
}
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string entryKey) =>
$"/api/image/{Uri.EscapeDataString(entryKey)}";
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
{
if (file is null) return;
row.IsUploading = true;
StateHasChanged();
try
{
await using var stream = file.OpenReadStream(maxAllowedSize: 50_000_000);
var result = await CmsReleaseService.UploadSessionHeroImageAsync(
row.Release.Id, stream, file.Name, file.ContentType);
if (result.Success)
{
// The endpoint returns no payload; the entry key is server-generated. Re-fetch the
// release so the hero thumbnail reflects the new key without guessing it.
var refreshed = await CmsReleaseService.GetByIdAsync(row.Release.Id);
if (refreshed.Success && refreshed.Value is { } release)
{
row.Release = release;
row.HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey;
}
Snackbar.Add($"Hero image set for '{row.Release.Title}'.", Severity.Success);
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Hero image upload failed: {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Hero image upload failed for release {ReleaseId}", row.Release.Id);
Snackbar.Add("Hero image upload failed — please try again.", Severity.Error);
}
finally
{
row.IsUploading = false;
StateHasChanged();
}
}
private sealed class SessionRow
{
public required ReleaseDto Release { get; set; }
public string? HeroImageEntryKey { get; set; }
public bool IsUploading { get; set; }
}
}
@@ -0,0 +1,15 @@
/* Scoped duplicate of the album-browser thumb idiom. Blazor CSS isolation is per-component, so the
class defined in CmsAlbumBrowser.razor.css does not reach this component's markup — a small,
intentional duplication rather than promoting a two-rule block to global app.css. */
.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,22 @@
@using DeepDrftModels.Enums
@* Cut-medium fields: the commercial release format. Plain explicit markup — no generics. *@
<MudItem xs="12" sm="6">
<MudSelect T="ReleaseType"
Value="ReleaseType"
ValueChanged="@(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>
@code {
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
}
@@ -0,0 +1,46 @@
@using DeepDrftModels.Enums
@* The single dispatch point for medium-conditional form fields. All five upload/edit forms embed this
one component; the @switch below is the ONLY place medium-specific form shape is decided. Adding a
medium is one new section component + one new switch arm here — nowhere else. *@
<MudGrid>
<MudItem xs="12" sm="6">
<MudSelect T="ReleaseMedium"
Value="Medium"
ValueChanged="@(v => MediumChanged.InvokeAsync(v))"
Label="Medium"
Variant="Variant.Outlined"
Disabled="Disabled">
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
<MudSelectItem T="ReleaseMedium" Value="medium">@medium</MudSelectItem>
}
</MudSelect>
</MudItem>
@switch (Medium)
{
case ReleaseMedium.Cut:
<CutFields ReleaseType="ReleaseType"
ReleaseTypeChanged="ReleaseTypeChanged"
Disabled="Disabled" />
break;
case ReleaseMedium.Session:
<SessionFields />
break;
case ReleaseMedium.Mix:
<MixFields />
break;
}
</MudGrid>
@code {
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
// Cut-only — bound through to CutFields. Ignored for Session/Mix.
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
}
@@ -0,0 +1,10 @@
@* Mix-medium fields. The high-res waveform is a server-side derived datum: the CMS fires a body-less
trigger (POST api/release/{id}/mix/waveform) after the release exists, so generation is managed
per-row in the Mixes browser, not at create time. On upload the trigger is fired automatically; this
section states that contract and carries no input of its own. *@
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
Mixes are single-track DJ releases. The high-resolution waveform is generated automatically
after upload; regenerate it any time from the <strong>Release Archive → Mixes</strong> browser.
</MudAlert>
</MudItem>
@@ -0,0 +1,37 @@
@using DeepDrftModels.Enums
@inject NavigationManager Navigation
@* Release Archive: one card per ReleaseMedium, driven off Enum.GetValues + a display-metadata table.
No hardcoded three-arm switch in markup — adding a medium surfaces a new card automatically and only
needs one new entry in MediumCards below. Card idiom mirrors CmsGenreBrowser (MudCard + swatch). *@
<MudGrid Spacing="3" Class="mt-2">
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
var info = MediumCards[medium];
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="1"
Style="cursor: pointer;"
@onclick="@(() => Navigation.NavigateTo(info.Route))">
<div class="@($"cms-medium-swatch cms-medium-swatch--{info.SwatchModifier}")"></div>
<MudCardContent>
<MudText Typo="Typo.h6">@info.Label</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@info.Descriptor</MudText>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@code {
private sealed record MediumCardInfo(string Label, string Descriptor, string SwatchModifier, string Route);
// The one place medium → display + navigation target lives. A future medium adds one entry here;
// the markup above is untouched. The enum→record dictionary is a switch, data-structured (§3.1).
private static readonly IReadOnlyDictionary<ReleaseMedium, MediumCardInfo> MediumCards =
new Dictionary<ReleaseMedium, MediumCardInfo>
{
[ReleaseMedium.Cut] = new("Cuts", "Studio singles, EPs, and albums", "cut", "/tracks/albums"),
[ReleaseMedium.Session] = new("Sessions", "Single-track live recordings", "session", "/tracks/sessions"),
[ReleaseMedium.Mix] = new("Mixes", "Single-track DJ mixes", "mix", "/tracks/mixes"),
};
}
@@ -0,0 +1,18 @@
.cms-medium-swatch {
width: 100%;
height: 80px;
background-color: var(--mud-palette-action-default-hover);
transition: background-color 0.2s ease;
}
.cms-medium-swatch--cut {
background-color: var(--mud-palette-primary-hover);
}
.cms-medium-swatch--session {
background-color: var(--mud-palette-secondary-hover);
}
.cms-medium-swatch--mix {
background-color: var(--mud-palette-tertiary-hover);
}
@@ -0,0 +1,10 @@
@* Session-medium fields. The hero image is resource-addressed (POST api/release/{id}/session/hero-image)
and therefore set after the release exists — managed per-row in the Sessions browser, not at create
time when no release id is yet assigned. This section states that contract so the admin knows where
the hero image is managed; it carries no input of its own. *@
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
Sessions are single-track live releases. After upload, set the hero image from the
<strong>Release Archive → Sessions</strong> browser.
</MudAlert>
</MudItem>
@@ -63,14 +63,7 @@
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>
<MediumFields @bind-Medium="_form.Medium" @bind-ReleaseType="_form.ReleaseType" Disabled="_busy" />
<MudNumericField @bind-Value="_form.TrackNumber"
Label="Track Number"
@@ -304,6 +297,10 @@
public string? ImagePath { get; set; }
public DateTime? ReleaseDate { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
// Drives ReleaseType visibility via MediumFields. NOTE: not persisted — PUT api/track/meta has
// no medium field, so a medium change on edit is form-shape only until the API grows one.
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
public int TrackNumber { get; set; } = 1;
public static TrackEditForm From(TrackDto track) => new()
@@ -317,6 +314,7 @@
? d.ToDateTime(TimeOnly.MinValue)
: null,
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
Medium = track.Release?.Medium ?? ReleaseMedium.Cut,
TrackNumber = track.TrackNumber
};
}
@@ -1,6 +1,7 @@
@page "/tracks"
@page "/tracks/albums"
@page "/tracks/genres"
@page "/tracks/archive"
@using DeepDrftManager.Services
@inject CmsTrackBrowserViewModel VM
@inject ICmsTrackService CmsTrackService
@@ -44,7 +45,7 @@
Class="mb-4">
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
<MudToggleItem Value="BrowseMode.Genres">Genres</MudToggleItem>
<MudToggleItem Value="BrowseMode.Archive">Release Archive</MudToggleItem>
</MudToggleGroup>
@if (VM.Mode == BrowseMode.Tracks)
@@ -57,8 +58,14 @@
IsLoading="VM.AlbumsLoading"
OnReleasesChanged="OnAlbumsChanged" />
}
else if (VM.Mode == BrowseMode.Archive)
{
<ReleaseArchiveBrowser />
}
else
{
@* Genre browse keeps its route (/tracks/genres) but lost its tab to Release Archive (§3.1).
Reachable by direct URL; no longer in the toggle group. *@
<CmsGenreBrowser Genres="VM.Genres"
IsLoading="VM.GenresLoading"
ExpandedGenre="@VM.ExpandedGenre"
@@ -85,11 +92,11 @@
protected override async Task OnInitializedAsync()
{
var uri = NavigationManager.Uri;
var initial = uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase)
? BrowseMode.Albums
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase)
? BrowseMode.Genres
: BrowseMode.Tracks;
var initial =
uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
: uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Archive
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
: BrowseMode.Tracks;
await VM.SwitchModeAsync(initial);
}
@@ -99,6 +106,7 @@
var path = mode switch
{
BrowseMode.Albums => "/tracks/albums",
BrowseMode.Archive => "/tracks/archive",
BrowseMode.Genres => "/tracks/genres",
_ => "/tracks"
};
@@ -5,6 +5,7 @@
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -32,6 +33,8 @@
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
<MediumFields @bind-Medium="_medium" @bind-ReleaseType="_releaseType" Disabled="_isUploading" />
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@if (_selectedImageFile is { } selectedImage)
@@ -104,6 +107,8 @@
private string _album = string.Empty;
private string _genre = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
private string? _errorMessage;
private bool _isUploading;
@@ -205,11 +210,24 @@
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
_selectedFile.Name,
createdByUserId,
releaseType: ReleaseType.Single,
trackNumber: 1);
_releaseType,
trackNumber: 1,
_medium);
if (result.Success)
{
// Mix uploads fire the server-side high-res waveform trigger (§3.4) — the CMS computes
// nothing. Non-blocking: a failed trigger is recoverable from the Mixes browser.
if (_medium == ReleaseMedium.Mix && result.Value?.ReleaseId is { } mixReleaseId)
{
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
if (!waveformResult.Success)
{
Logger.LogWarning("TrackNew: mix waveform trigger failed for release {ReleaseId}", mixReleaseId);
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
}
}
// The upload endpoint does not accept an imagePath, so link the cover art with a
// follow-up metadata update — same two-step pattern TrackEdit uses.
if (_imagePath is { } imgPath && result.Value is { } created)
+4
View File
@@ -23,6 +23,10 @@ builder.Services.AddMudServices();
// DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer.
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
// CMS release operations (medium-filtered browse + Session/Mix media ops) over HTTP to the
// DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService.
builder.Services.AddScoped<ICmsReleaseService, CmsReleaseService>();
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
builder.Services.AddScoped<CmsTrackBrowserViewModel>();
@@ -0,0 +1,222 @@
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;
namespace DeepDrftManager.Services;
/// <summary>
/// HTTP client over DeepDrftAPI's <c>api/release</c> family for CMS release operations. Mirrors
/// <see cref="CmsTrackService"/>: the Manager is InteractiveServer-only with no in-process data
/// layer, so every read and write is a network call. The ApiKey is baked into the
/// <c>DeepDrft.Content.Cms</c> named client's default headers; the unauthenticated reads still go
/// through it (the extra header is harmless on public endpoints).
/// </summary>
public class CmsReleaseService : ICmsReleaseService
{
private const string ContentCmsClientName = "DeepDrft.Content.Cms";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CmsReleaseService> _logger;
public CmsReleaseService(
IHttpClientFactory httpClientFactory,
ILogger<CmsReleaseService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
ReleaseMedium? medium,
int page, int pageSize, string? sortColumn, bool sortDescending,
CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
var query = $"api/release?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
if (medium is { } m)
{
query += $"&medium={Uri.EscapeDataString(m.ToString())}";
}
if (!string.IsNullOrWhiteSpace(sortColumn))
{
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
}
HttpResponseMessage response;
try
{
response = await client.GetAsync(query, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for release page (medium {Medium})", medium);
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API release page failed: {Status}", (int)response.StatusCode);
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Failed to load releases.");
}
PagedResult<ReleaseDto>? paged;
try
{
paged = await response.Content.ReadFromJsonAsync<PagedResult<ReleaseDto>>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize release page from Content API response");
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API returned an unexpected response.");
}
if (paged is null)
{
_logger.LogError("Content API returned a null release page");
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<PagedResult<ReleaseDto>>.CreatePassResult(paged);
}
}
public async Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync($"api/release/{id}", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for release {ReleaseId}", id);
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API release lookup failed for {ReleaseId}: {Status}", id, (int)response.StatusCode);
return ResultContainer<ReleaseDto?>.CreateFailResult("Failed to load release.");
}
ReleaseDto? release;
try
{
release = await response.Content.ReadFromJsonAsync<ReleaseDto>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize ReleaseDto from Content API response");
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API returned an unexpected response.");
}
return ResultContainer<ReleaseDto?>.CreatePassResult(release);
}
}
public async Task<Result> UploadSessionHeroImageAsync(
long releaseId,
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default)
{
using var multipart = new MultipartFormDataContent();
var imageContent = new StreamContent(imageStream);
imageContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
// Field name "image" matches the controller's [FromForm] IFormFile image parameter.
multipart.Add(imageContent, "image", fileName);
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, $"api/release/{releaseId}/session/hero-image")
{
Content = multipart
};
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for hero-image upload 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);
var statusCode = (int)response.StatusCode;
if (statusCode >= 500)
{
_logger.LogError("Content API returned {Status} for hero-image upload of release {ReleaseId}: {Body}", statusCode, releaseId, body);
return Result.CreateFailResult("Hero image upload failed on the content server.");
}
// 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is.
_logger.LogWarning("Content API rejected hero-image upload for release {ReleaseId}: {Status} {Body}", releaseId, statusCode, body);
return Result.CreateFailResult(
string.IsNullOrWhiteSpace(body) ? $"Hero image upload rejected ({statusCode})." : body);
}
}
public async Task<Result> GenerateMixWaveformAsync(long releaseId, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync($"api/release/{releaseId}/mix/waveform", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for mix waveform generation 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("Mix audio not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API mix waveform generation failed for release {ReleaseId}: {Status} {Body}", releaseId, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to generate mix waveform.");
}
}
}
@@ -2,11 +2,12 @@ using DeepDrftModels.DTOs;
namespace DeepDrftManager.Services;
/// <summary>The three browse dimensions for the /tracks page.</summary>
/// <summary>The browse dimensions for the /tracks page.</summary>
public enum BrowseMode
{
Tracks,
Albums,
Archive,
Genres,
}
@@ -44,6 +44,7 @@ public class CmsTrackService : ICmsTrackService
long createdByUserId,
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
CancellationToken ct = default)
{
// Rebuild the multipart container so the boundary is owned by HttpClient and the
@@ -63,6 +64,9 @@ public class CmsTrackService : ICmsTrackService
multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId");
multipart.Add(new StringContent(releaseType.ToString()), "releaseType");
multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber");
// Forward-compatible: the upload endpoint does not bind a "medium" field yet (server defaults
// to Cut). Sent so the value round-trips once the API grows the parameter; ignored until then.
multipart.Add(new StringContent(medium.ToString()), "medium");
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
@@ -0,0 +1,51 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftManager.Services;
/// <summary>
/// CMS-side release operations for the Manager host. Mirrors <see cref="ICmsTrackService"/>: every
/// read and write goes over HTTP to DeepDrftAPI's <c>api/release</c> family, which is the single
/// authority over both the SQL metadata store and the binary vault. The Manager holds no in-process
/// data layer.
/// </summary>
public interface ICmsReleaseService
{
/// <summary>
/// Fetch a page of releases from <c>GET api/release</c>, optionally filtered to one
/// <paramref name="medium"/>. The matching medium's metadata satellite is populated on each row;
/// the others are null. Null medium returns all releases unfiltered.
/// </summary>
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
ReleaseMedium? medium,
int page, int pageSize, string? sortColumn, bool sortDescending,
CancellationToken ct = default);
/// <summary>
/// Fetch a single release with both metadata navs from <c>GET api/release/{id}</c> (nulls for the
/// non-matching medium). A 404 returns a passing result with a null value.
/// </summary>
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken ct = default);
/// <summary>
/// Upload a Session hero image via <c>POST api/release/{id}/session/hero-image</c> (multipart).
/// The server stores it in the image vault and sets <c>SessionMetadata.HeroImageEntryKey</c>.
/// Maps a 404 to a "Release not found." failure; relays 4xx validation text as-is.
/// </summary>
Task<Result> UploadSessionHeroImageAsync(
long releaseId,
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default);
/// <summary>
/// Trigger high-resolution waveform generation for a Mix via
/// <c>POST api/release/{id}/mix/waveform</c> (no body). The server fetches the mix audio from its
/// own vault, computes the datum, stores it, and sets <c>MixMetadata.WaveformEntryKey</c>. Maps a
/// 404 to a "Mix audio not found." failure.
/// </summary>
Task<Result> GenerateMixWaveformAsync(long releaseId, CancellationToken ct = default);
}
@@ -18,6 +18,10 @@ public interface ICmsTrackService
/// orphan is handled and logged server-side; here it surfaces as a failed result.
/// <paramref name="originalFileName"/> is the browser's filename, captured at upload time and
/// stored as metadata; it is not user-editable afterwards.
/// <paramref name="medium"/> sets the parent release's <see cref="ReleaseMedium"/>. NOTE: the
/// current <c>POST api/track/upload</c> endpoint has no <c>medium</c> form field, so the value is
/// sent forward-compatibly and ignored server-side until the API binds it (Cut is the server
/// default). Wiring the selector through here keeps the CMS ready for that API change.
/// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
@@ -32,6 +36,7 @@ public interface ICmsTrackService
long createdByUserId,
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
CancellationToken ct = default);
/// <summary>