Add CMS waveform pre-processing panel with backfill endpoints
GET api/track/waveform-status and POST api/track/{id}/waveform (ApiKey);
CmsTrackService methods; TrackPreProcessing page with per-row and
sequential bulk generation; nav links from TrackList and Index.
This commit is contained in:
@@ -72,6 +72,39 @@ public class TrackController : ControllerBase
|
|||||||
return Ok(result.Value);
|
return Ok(result.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET api/track/waveform-status ([ApiKeyAuthorize])
|
||||||
|
// Admin backfill view: returns every track with a flag for whether a waveform profile is
|
||||||
|
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
|
||||||
|
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
|
||||||
|
// segment is never treated as a trackId.
|
||||||
|
[ApiKeyAuthorize]
|
||||||
|
[HttpGet("waveform-status")]
|
||||||
|
public async Task<ActionResult> GetWaveformStatus()
|
||||||
|
{
|
||||||
|
var tracks = await _sqlTrackService.GetAll();
|
||||||
|
if (!tracks.Success || tracks.Value is null)
|
||||||
|
{
|
||||||
|
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
_logger.LogError("GetWaveformStatus failed to load tracks: {Error}", error);
|
||||||
|
return StatusCode(500, "Failed to load tracks");
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = new List<WaveformStatusDto>(tracks.Value.Count);
|
||||||
|
foreach (var track in tracks.Value)
|
||||||
|
{
|
||||||
|
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
|
||||||
|
status.Add(new WaveformStatusDto
|
||||||
|
{
|
||||||
|
TrackId = track.Id,
|
||||||
|
EntryKey = track.EntryKey,
|
||||||
|
TrackName = track.TrackName,
|
||||||
|
HasProfile = profile is not null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out.
|
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||||
// Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never
|
// Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never
|
||||||
// touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write.
|
// touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write.
|
||||||
@@ -375,6 +408,32 @@ public class TrackController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
|
||||||
|
// Admin backfill: compute and store a waveform profile for an existing track from its vault
|
||||||
|
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
|
||||||
|
// WAV cannot be decoded or the vault write fails. Used by the CMS PreProcessing panel for
|
||||||
|
// tracks that predate the WaveformSeeker feature.
|
||||||
|
[ApiKeyAuthorize]
|
||||||
|
[HttpPost("{trackId}/waveform")]
|
||||||
|
public async Task<ActionResult> GenerateWaveform(string trackId)
|
||||||
|
{
|
||||||
|
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||||
|
if (audio is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
|
||||||
|
if (!stored)
|
||||||
|
{
|
||||||
|
_logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
|
||||||
|
return StatusCode(500, "Failed to generate waveform profile.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
[ApiKeyAuthorize]
|
[ApiKeyAuthorize]
|
||||||
[HttpPut("{trackId}")]
|
[HttpPut("{trackId}")]
|
||||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||||
|
|||||||
@@ -7,5 +7,19 @@
|
|||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudText Typo="Typo.h3" GutterBottom="true">DeepDrft CMS</MudText>
|
<MudText Typo="Typo.h3" GutterBottom="true">DeepDrft CMS</MudText>
|
||||||
<MudText Typo="Typo.body1">Administration panel — under construction.</MudText>
|
<MudText Typo="Typo.body1" Class="mb-4">Administration panel.</MudText>
|
||||||
|
<MudStack Row="true" Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.LibraryMusic"
|
||||||
|
Href="/tracks">
|
||||||
|
Tracks
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||||
|
Href="/tracks/preprocessing">
|
||||||
|
Waveform Pre-Processing
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
|||||||
@@ -12,12 +12,20 @@
|
|||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||||
<MudText Typo="Typo.h3">Tracks</MudText>
|
<MudText Typo="Typo.h3">Tracks</MudText>
|
||||||
<MudButton Variant="Variant.Filled"
|
<MudStack Row="true" Spacing="2">
|
||||||
Color="Color.Primary"
|
<MudButton Variant="Variant.Outlined"
|
||||||
StartIcon="@Icons.Material.Filled.Add"
|
Color="Color.Primary"
|
||||||
Href="/tracks/new">
|
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||||
Add Track
|
Href="/tracks/preprocessing">
|
||||||
</MudButton>
|
Waveform Pre-Processing
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Href="/tracks/new">
|
||||||
|
Add Track
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
<MudTable T="TrackDto"
|
<MudTable T="TrackDto"
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
@page "/tracks/preprocessing"
|
||||||
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
<PageTitle>Waveform Pre-Processing — DeepDrft CMS</PageTitle>
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||||
|
<MudStack Spacing="0">
|
||||||
|
<MudText Typo="Typo.h3">Waveform Pre-Processing</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mud-text-secondary">
|
||||||
|
Generate loudness profiles for tracks that predate the waveform seeker.
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||||
|
Disabled="@(_bulkRunning || _missingCount == 0)"
|
||||||
|
OnClick="GenerateAllMissing">
|
||||||
|
@if (_bulkRunning)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||||
|
<span>Generating @_bulkDone / @_bulkTotal…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Generate All Missing (@_missingCount)</span>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudTable T="WaveformStatusDto"
|
||||||
|
Items="_rows"
|
||||||
|
Loading="_loading"
|
||||||
|
Hover="true"
|
||||||
|
Striped="true"
|
||||||
|
Dense="true"
|
||||||
|
Bordered="false"
|
||||||
|
FixedHeader="true">
|
||||||
|
<NoRecordsContent>
|
||||||
|
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||||
|
</NoRecordsContent>
|
||||||
|
<LoadingContent>
|
||||||
|
<MudText Typo="Typo.body1">Loading waveform status…</MudText>
|
||||||
|
</LoadingContent>
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Track Name</MudTh>
|
||||||
|
<MudTh>Entry Key</MudTh>
|
||||||
|
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
||||||
|
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||||
|
<MudTd DataLabel="Entry Key">
|
||||||
|
<MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="Profile">
|
||||||
|
@if (context.HasProfile)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text"
|
||||||
|
Icon="@Icons.Material.Filled.CheckCircle">Stored</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Text"
|
||||||
|
Icon="@Icons.Material.Filled.Cancel">Missing</MudChip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="Actions">
|
||||||
|
@if (!context.HasProfile)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Outlined"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||||
|
Disabled="@(_bulkRunning || IsGenerating(context.EntryKey))"
|
||||||
|
OnClick="@(() => GenerateOne(context))">
|
||||||
|
@if (IsGenerating(context.EntryKey))
|
||||||
|
{
|
||||||
|
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||||
|
<span>Generating…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Generate</span>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudContainer>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using DeepDrftManager.Services;
|
||||||
|
using DeepDrftModels.DTOs;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using MudBlazor;
|
||||||
|
|
||||||
|
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||||
|
|
||||||
|
public partial class TrackPreProcessing : ComponentBase
|
||||||
|
{
|
||||||
|
private List<WaveformStatusDto> _rows = new();
|
||||||
|
private readonly HashSet<string> _generating = new();
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _bulkRunning;
|
||||||
|
private int _bulkTotal;
|
||||||
|
private int _bulkDone;
|
||||||
|
|
||||||
|
private int _missingCount => _rows.Count(r => !r.HasProfile);
|
||||||
|
|
||||||
|
[Inject] private ICmsTrackService CmsTrackService { get; set; } = default!;
|
||||||
|
[Inject] private ISnackbar Snackbar { get; set; } = default!;
|
||||||
|
[Inject] private ILogger<TrackPreProcessing> Logger { get; set; } = default!;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadStatus()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||||
|
_loading = false;
|
||||||
|
|
||||||
|
if (!result.Success || result.Value is null)
|
||||||
|
{
|
||||||
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
|
||||||
|
_rows = new List<WaveformStatusDto>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_rows = result.Value.OrderBy(r => r.HasProfile).ThenBy(r => r.TrackName).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsGenerating(string entryKey) => _generating.Contains(entryKey);
|
||||||
|
|
||||||
|
private async Task GenerateOne(WaveformStatusDto row)
|
||||||
|
{
|
||||||
|
if (!await GenerateForRow(row))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add($"Generated profile for '{row.TrackName}'.", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GenerateAllMissing()
|
||||||
|
{
|
||||||
|
var missing = _rows.Where(r => !r.HasProfile).ToList();
|
||||||
|
if (missing.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bulkRunning = true;
|
||||||
|
_bulkTotal = missing.Count;
|
||||||
|
_bulkDone = 0;
|
||||||
|
var failures = 0;
|
||||||
|
|
||||||
|
// Sequential by design: one request at a time so a large backfill does not flood the API
|
||||||
|
// with concurrent WAV decodes.
|
||||||
|
foreach (var row in missing)
|
||||||
|
{
|
||||||
|
if (!await GenerateForRow(row))
|
||||||
|
{
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
_bulkDone++;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
_bulkRunning = false;
|
||||||
|
|
||||||
|
var succeeded = missing.Count - failures;
|
||||||
|
if (failures == 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs generation for a single row, flipping its status on success. Returns false on failure
|
||||||
|
/// (a snackbar is raised here for the per-row path; the bulk path aggregates a summary). Marks
|
||||||
|
/// the row busy for the duration so its button shows a spinner and stays disabled.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> GenerateForRow(WaveformStatusDto row)
|
||||||
|
{
|
||||||
|
_generating.Add(row.EntryKey);
|
||||||
|
StateHasChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await CmsTrackService.GenerateWaveformProfileAsync(row.EntryKey);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
row.HasProfile = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||||
|
if (!_bulkRunning)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Generate failed for '{row.TrackName}': {error}", Severity.Error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", row.EntryKey);
|
||||||
|
if (!_bulkRunning)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Generate failed for '{row.TrackName}' — please try again.", Severity.Error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_generating.Remove(row.EntryKey);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -281,4 +281,81 @@ public class CmsTrackService : ICmsTrackService
|
|||||||
return Result.CreateFailResult("Failed to update track.");
|
return Result.CreateFailResult("Failed to update track.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||||
|
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await client.GetAsync("api/track/waveform-status", ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Content API call failed for waveform status");
|
||||||
|
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API is unreachable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Content API waveform status failed: {Status}", (int)response.StatusCode);
|
||||||
|
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Failed to load waveform status.");
|
||||||
|
}
|
||||||
|
|
||||||
|
WaveformStatusDto[]? status;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status = await response.Content.ReadFromJsonAsync<WaveformStatusDto[]>(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to deserialize waveform status from Content API response");
|
||||||
|
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an unexpected response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status is null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Content API returned a null waveform status list");
|
||||||
|
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an empty response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResultContainer<WaveformStatusDto[]>.CreatePassResult(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||||
|
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform", null, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Content API call failed for waveform generation of {EntryKey}", entryKey);
|
||||||
|
return Result.CreateFailResult("Content API is unreachable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return Result.CreatePassResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return Result.CreateFailResult("Track audio not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogError("Content API waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
|
||||||
|
return Result.CreateFailResult("Failed to generate waveform profile.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,4 +55,16 @@ public interface ICmsTrackService
|
|||||||
long id, string trackName, string artist,
|
long id, string trackName, string artist,
|
||||||
string? album, string? genre, DateOnly? releaseDate,
|
string? album, string? genre, DateOnly? releaseDate,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch per-track waveform profile status from <c>GET api/track/waveform-status</c> for the
|
||||||
|
/// CMS PreProcessing panel. Unpaged — the admin catalogue is small.
|
||||||
|
/// </summary>
|
||||||
|
Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trigger waveform profile generation for a single track via
|
||||||
|
/// <c>POST api/track/{entryKey}/waveform</c>. Maps a 404 to a "Track audio not found." failure.
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace DeepDrftModels.DTOs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
|
||||||
|
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
|
||||||
|
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
|
||||||
|
/// vault key used to trigger generation for a missing profile.
|
||||||
|
/// </summary>
|
||||||
|
public class WaveformStatusDto
|
||||||
|
{
|
||||||
|
public long TrackId { get; set; }
|
||||||
|
public string EntryKey { get; set; } = string.Empty;
|
||||||
|
public string TrackName { get; set; } = string.Empty;
|
||||||
|
public bool HasProfile { get; set; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user