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];
+
+ Navigation.NavigateTo(info.Route))">
+
+
+ @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);
///