From 2f47efeb46014391ada2efd1c3abd9b68249388a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 12 Jun 2026 23:07:15 -0400 Subject: [PATCH] CMS Phase 9 Wave 3: Release Archive tab, medium selector, Session/Mix browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Pages/Tracks/AlbumHeaderFields.razor | 31 ++- .../Components/Pages/Tracks/BatchEdit.razor | 12 + .../Components/Pages/Tracks/BatchUpload.razor | 111 +++++++-- .../Pages/Tracks/CmsMixBrowser.razor | 165 +++++++++++++ .../Pages/Tracks/CmsMixBrowser.razor.css | 15 ++ .../Pages/Tracks/CmsSessionBrowser.razor | 178 ++++++++++++++ .../Pages/Tracks/CmsSessionBrowser.razor.css | 15 ++ .../Components/Pages/Tracks/CutFields.razor | 22 ++ .../Pages/Tracks/MediumFields.razor | 46 ++++ .../Components/Pages/Tracks/MixFields.razor | 10 + .../Pages/Tracks/ReleaseArchiveBrowser.razor | 37 +++ .../Tracks/ReleaseArchiveBrowser.razor.css | 18 ++ .../Pages/Tracks/SessionFields.razor | 10 + .../Components/Pages/Tracks/TrackEdit.razor | 14 +- .../Components/Pages/Tracks/TrackList.razor | 20 +- .../Components/Pages/Tracks/TrackNew.razor | 22 +- DeepDrftManager/Program.cs | 4 + DeepDrftManager/Services/CmsReleaseService.cs | 222 ++++++++++++++++++ .../Services/CmsTrackBrowserViewModel.cs | 3 +- DeepDrftManager/Services/CmsTrackService.cs | 4 + .../Services/ICmsReleaseService.cs | 51 ++++ DeepDrftManager/Services/ICmsTrackService.cs | 5 + 22 files changed, 970 insertions(+), 45 deletions(-) create mode 100644 DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor create mode 100644 DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor.css create mode 100644 DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor create mode 100644 DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor.css create mode 100644 DeepDrftManager/Components/Pages/Tracks/CutFields.razor create mode 100644 DeepDrftManager/Components/Pages/Tracks/MediumFields.razor create mode 100644 DeepDrftManager/Components/Pages/Tracks/MixFields.razor create mode 100644 DeepDrftManager/Components/Pages/Tracks/ReleaseArchiveBrowser.razor create mode 100644 DeepDrftManager/Components/Pages/Tracks/ReleaseArchiveBrowser.razor.css create mode 100644 DeepDrftManager/Components/Pages/Tracks/SessionFields.razor create mode 100644 DeepDrftManager/Services/CmsReleaseService.cs create mode 100644 DeepDrftManager/Services/ICmsReleaseService.cs diff --git a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor index b891655..8af8370 100644 --- a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor @@ -22,15 +22,6 @@ T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" Disabled="Disabled" /> - - - @foreach (var rt in Enum.GetValues()) - { - @rt - } - - @@ -70,6 +61,12 @@ + + + + @code { @@ -83,6 +80,8 @@ [Parameter] public EventCallback ReleaseDateChanged { get; set; } [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; [Parameter] public EventCallback ReleaseTypeChanged { get; set; } + [Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; + [Parameter] public EventCallback MediumChanged { get; set; } [Parameter] public IBrowserFile? SelectedImageFile { get; set; } [Parameter] public EventCallback 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); diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index baf0df9..d18c1e9 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -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 @@ + @* 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. *@ _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 diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor index 918b712..8532df6 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchUpload.razor @@ -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" /> - - - - + @if (_medium == ReleaseMedium.Cut) + { + + + + - - - - - - + + + + + + + } + else + { + @* Session/Mix are single-track releases — no multi-track master list. A single WAV slot. *@ + + + Track + + @if (_tracks.Count > 0) + { + + Selected: @(_tracks[0].WavFile?.Name ?? "—") + } + + + } @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 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++; } diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor new file mode 100644 index 0000000..dca76a2 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor @@ -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 Logger + +Mixes — DeepDrft CMS + + + + Back to Release Archive + + + Mixes + + @if (_loading) + { + + } + else if (_rows.Count == 0) + { + No mixes found. + } + else + { + + + Cover + Mix + Artist + Waveform + Actions + + + + @if (!string.IsNullOrEmpty(context.Release.ImagePath)) + { +
+ } + else + { +
+ } +
+ @context.Release.Title + @context.Release.Artist + + @if (context.HasWaveform) + { + + + + } + else + { + + + + } + + + + @if (context.IsGenerating) + { + + Generating… + } + else + { + @(context.HasWaveform ? "Regenerate" : "Generate") + } + + +
+
+ } +
+ +@code { + private List _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(); + } + _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; } + } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor.css b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor.css new file mode 100644 index 0000000..dcb8151 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CmsMixBrowser.razor.css @@ -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); +} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor new file mode 100644 index 0000000..5e93ccc --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor @@ -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 Logger +@inject NavigationManager Navigation + +Sessions — DeepDrft CMS + + + + Back to Release Archive + + + Sessions + + @if (_loading) + { + + } + else if (_rows.Count == 0) + { + No sessions found. + } + else + { + + + Cover + Hero + Session + Artist + Actions + + + + @if (!string.IsNullOrEmpty(context.Release.ImagePath)) + { +
+ } + else + { +
+ } +
+ + @if (context.HeroImageEntryKey is { Length: > 0 } heroKey) + { +
+ } + else + { +
+ } +
+ @context.Release.Title + @context.Release.Artist + + + + + @if (context.IsUploading) + { + + Uploading… + } + else + { + @(context.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero") + } + + + + +
+
+ } +
+ +@code { + private List _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(); + } + _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; } + } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor.css b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor.css new file mode 100644 index 0000000..dcb8151 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CmsSessionBrowser.razor.css @@ -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); +} diff --git a/DeepDrftManager/Components/Pages/Tracks/CutFields.razor b/DeepDrftManager/Components/Pages/Tracks/CutFields.razor new file mode 100644 index 0000000..867c504 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/CutFields.razor @@ -0,0 +1,22 @@ +@using DeepDrftModels.Enums + +@* Cut-medium fields: the commercial release format. Plain explicit markup — no generics. *@ + + + @foreach (var rt in Enum.GetValues()) + { + @rt + } + + + +@code { + [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + [Parameter] public EventCallback ReleaseTypeChanged { get; set; } + [Parameter] public bool Disabled { get; set; } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/MediumFields.razor b/DeepDrftManager/Components/Pages/Tracks/MediumFields.razor new file mode 100644 index 0000000..45a5cde --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/MediumFields.razor @@ -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. *@ + + + + @foreach (var medium in Enum.GetValues()) + { + @medium + } + + + + @switch (Medium) + { + case ReleaseMedium.Cut: + + break; + case ReleaseMedium.Session: + + break; + case ReleaseMedium.Mix: + + break; + } + + +@code { + [Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; + [Parameter] public EventCallback MediumChanged { get; set; } + + // Cut-only — bound through to CutFields. Ignored for Session/Mix. + [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; + [Parameter] public EventCallback ReleaseTypeChanged { get; set; } + + [Parameter] public bool Disabled { get; set; } +} diff --git a/DeepDrftManager/Components/Pages/Tracks/MixFields.razor b/DeepDrftManager/Components/Pages/Tracks/MixFields.razor new file mode 100644 index 0000000..093d118 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/MixFields.razor @@ -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. *@ + + + Mixes are single-track DJ releases. The high-resolution waveform is generated automatically + after upload; regenerate it any time from the Release Archive → Mixes browser. + + diff --git a/DeepDrftManager/Components/Pages/Tracks/ReleaseArchiveBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/ReleaseArchiveBrowser.razor new file mode 100644 index 0000000..0c169df --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/ReleaseArchiveBrowser.razor @@ -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). *@ + + @foreach (var medium in Enum.GetValues()) + { + var info = MediumCards[medium]; + + +
+ + @info.Label + @info.Descriptor + +
+
+ } +
+ +@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 MediumCards = + new Dictionary + { + [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"), + }; +} diff --git a/DeepDrftManager/Components/Pages/Tracks/ReleaseArchiveBrowser.razor.css b/DeepDrftManager/Components/Pages/Tracks/ReleaseArchiveBrowser.razor.css new file mode 100644 index 0000000..f49db7a --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/ReleaseArchiveBrowser.razor.css @@ -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); +} diff --git a/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor b/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor new file mode 100644 index 0000000..227dc7c --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/SessionFields.razor @@ -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. *@ + + + Sessions are single-track live releases. After upload, set the hero image from the + Release Archive → Sessions browser. + + diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index c11a294..a1d4f68 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -63,14 +63,7 @@ Label="Genre" Variant="Variant.Outlined" /> - - @foreach (var releaseType in Enum.GetValues()) - { - @releaseType - } - + 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 }; } diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index cb6790b..5fd0acb 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -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"> Tracks Releases - Genres + Release Archive @if (VM.Mode == BrowseMode.Tracks) @@ -57,8 +58,14 @@ IsLoading="VM.AlbumsLoading" OnReleasesChanged="OnAlbumsChanged" /> } + else if (VM.Mode == BrowseMode.Archive) + { + + } 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. *@ "/tracks/albums", + BrowseMode.Archive => "/tracks/archive", BrowseMode.Genres => "/tracks/genres", _ => "/tracks" }; diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor b/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor index 905c472..0ce625d 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackNew.razor @@ -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 @@ + + @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) diff --git a/DeepDrftManager/Program.cs b/DeepDrftManager/Program.cs index 3ccb7e6..6c3941d 100644 --- a/DeepDrftManager/Program.cs +++ b/DeepDrftManager/Program.cs @@ -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(); +// 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(); + // Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets). builder.Services.AddScoped(); diff --git a/DeepDrftManager/Services/CmsReleaseService.cs b/DeepDrftManager/Services/CmsReleaseService.cs new file mode 100644 index 0000000..e350e17 --- /dev/null +++ b/DeepDrftManager/Services/CmsReleaseService.cs @@ -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; + +/// +/// HTTP client over DeepDrftAPI's api/release family for CMS release operations. Mirrors +/// : 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 +/// DeepDrft.Content.Cms named client's default headers; the unauthenticated reads still go +/// through it (the extra header is harmless on public endpoints). +/// +public class CmsReleaseService : ICmsReleaseService +{ + private const string ContentCmsClientName = "DeepDrft.Content.Cms"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public CmsReleaseService( + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task>> 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>.CreateFailResult("Content API is unreachable."); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Content API release page failed: {Status}", (int)response.StatusCode); + return ResultContainer>.CreateFailResult("Failed to load releases."); + } + + PagedResult? paged; + try + { + paged = await response.Content.ReadFromJsonAsync>(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize release page from Content API response"); + return ResultContainer>.CreateFailResult("Content API returned an unexpected response."); + } + + if (paged is null) + { + _logger.LogError("Content API returned a null release page"); + return ResultContainer>.CreateFailResult("Content API returned an empty response."); + } + + return ResultContainer>.CreatePassResult(paged); + } + } + + public async Task> 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.CreateFailResult("Content API is unreachable."); + } + + using (response) + { + if (response.StatusCode == HttpStatusCode.NotFound) + { + return ResultContainer.CreatePassResult(null); + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Content API release lookup failed for {ReleaseId}: {Status}", id, (int)response.StatusCode); + return ResultContainer.CreateFailResult("Failed to load release."); + } + + ReleaseDto? release; + try + { + release = await response.Content.ReadFromJsonAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize ReleaseDto from Content API response"); + return ResultContainer.CreateFailResult("Content API returned an unexpected response."); + } + + return ResultContainer.CreatePassResult(release); + } + } + + public async Task 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 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."); + } + } +} diff --git a/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs b/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs index 5a65c2f..89c2d5f 100644 --- a/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs +++ b/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs @@ -2,11 +2,12 @@ using DeepDrftModels.DTOs; namespace DeepDrftManager.Services; -/// The three browse dimensions for the /tracks page. +/// The browse dimensions for the /tracks page. public enum BrowseMode { Tracks, Albums, + Archive, Genres, } diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index a43e0c4..becea65 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -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 }; diff --git a/DeepDrftManager/Services/ICmsReleaseService.cs b/DeepDrftManager/Services/ICmsReleaseService.cs new file mode 100644 index 0000000..a144b75 --- /dev/null +++ b/DeepDrftManager/Services/ICmsReleaseService.cs @@ -0,0 +1,51 @@ +using DeepDrftModels.DTOs; +using DeepDrftModels.Enums; +using Models.Common; +using NetBlocks.Models; + +namespace DeepDrftManager.Services; + +/// +/// CMS-side release operations for the Manager host. Mirrors : every +/// read and write goes over HTTP to DeepDrftAPI's api/release family, which is the single +/// authority over both the SQL metadata store and the binary vault. The Manager holds no in-process +/// data layer. +/// +public interface ICmsReleaseService +{ + /// + /// Fetch a page of releases from GET api/release, optionally filtered to one + /// . The matching medium's metadata satellite is populated on each row; + /// the others are null. Null medium returns all releases unfiltered. + /// + Task>> GetPagedAsync( + ReleaseMedium? medium, + int page, int pageSize, string? sortColumn, bool sortDescending, + CancellationToken ct = default); + + /// + /// Fetch a single release with both metadata navs from GET api/release/{id} (nulls for the + /// non-matching medium). A 404 returns a passing result with a null value. + /// + Task> GetByIdAsync(long id, CancellationToken ct = default); + + /// + /// Upload a Session hero image via POST api/release/{id}/session/hero-image (multipart). + /// The server stores it in the image vault and sets SessionMetadata.HeroImageEntryKey. + /// Maps a 404 to a "Release not found." failure; relays 4xx validation text as-is. + /// + Task UploadSessionHeroImageAsync( + long releaseId, + Stream imageStream, + string fileName, + string contentType, + CancellationToken ct = default); + + /// + /// Trigger high-resolution waveform generation for a Mix via + /// POST api/release/{id}/mix/waveform (no body). The server fetches the mix audio from its + /// own vault, computes the datum, stores it, and sets MixMetadata.WaveformEntryKey. Maps a + /// 404 to a "Mix audio not found." failure. + /// + Task GenerateMixWaveformAsync(long releaseId, CancellationToken ct = default); +} diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index 6362f94..0be2686 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -18,6 +18,10 @@ public interface ICmsTrackService /// orphan is handled and logged server-side; here it surfaces as a failed result. /// is the browser's filename, captured at upload time and /// stored as metadata; it is not user-editable afterwards. + /// sets the parent release's . NOTE: the + /// current POST api/track/upload endpoint has no medium 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. /// Task> UploadTrackAsync( Stream wavStream, @@ -32,6 +36,7 @@ public interface ICmsTrackService long createdByUserId, ReleaseType releaseType, int trackNumber, + ReleaseMedium medium = ReleaseMedium.Cut, CancellationToken ct = default); ///