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
@@ -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++;
}