Merge branch 'p9-w3-cms' into dev
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user