diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index a44d536..6368247 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -72,6 +72,39 @@ public class TrackController : ControllerBase 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 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(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. // 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. @@ -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 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] [HttpPut("{trackId}")] public async Task PutTrack(string trackId, [FromBody] AudioBinaryDto track) diff --git a/DeepDrftManager/Components/Pages/Index.razor b/DeepDrftManager/Components/Pages/Index.razor index 3e387dd..9dcc89a 100644 --- a/DeepDrftManager/Components/Pages/Index.razor +++ b/DeepDrftManager/Components/Pages/Index.razor @@ -7,5 +7,19 @@ DeepDrft CMS - Administration panel — under construction. + Administration panel. + + + Tracks + + + Waveform Pre-Processing + + diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index 4230130..a23f809 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -12,12 +12,20 @@ Tracks - - Add Track - + + + Waveform Pre-Processing + + + Add Track + + Waveform Pre-Processing — DeepDrft CMS + + + + + Waveform Pre-Processing + + Generate loudness profiles for tracks that predate the waveform seeker. + + + + @if (_bulkRunning) + { + + Generating @_bulkDone / @_bulkTotal… + } + else + { + Generate All Missing (@_missingCount) + } + + + + + + No tracks found. + + + Loading waveform status… + + + Track Name + Entry Key + Profile + Actions + + + @context.TrackName + + @context.EntryKey + + + @if (context.HasProfile) + { + Stored + } + else + { + Missing + } + + + @if (!context.HasProfile) + { + + @if (IsGenerating(context.EntryKey)) + { + + Generating… + } + else + { + Generate + } + + } + + + + diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackPreProcessing.razor.cs b/DeepDrftManager/Components/Pages/Tracks/TrackPreProcessing.razor.cs new file mode 100644 index 0000000..41da1c8 --- /dev/null +++ b/DeepDrftManager/Components/Pages/Tracks/TrackPreProcessing.razor.cs @@ -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 _rows = new(); + private readonly HashSet _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 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(); + 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); + } + } + + /// + /// 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. + /// + private async Task 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(); + } + } +} diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index 58088c1..d217b48 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -281,4 +281,81 @@ public class CmsTrackService : ICmsTrackService return Result.CreateFailResult("Failed to update track."); } } + + public async Task> 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.CreateFailResult("Content API is unreachable."); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Content API waveform status failed: {Status}", (int)response.StatusCode); + return ResultContainer.CreateFailResult("Failed to load waveform status."); + } + + WaveformStatusDto[]? status; + try + { + status = await response.Content.ReadFromJsonAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize waveform status from Content API response"); + return ResultContainer.CreateFailResult("Content API returned an unexpected response."); + } + + if (status is null) + { + _logger.LogError("Content API returned a null waveform status list"); + return ResultContainer.CreateFailResult("Content API returned an empty response."); + } + + return ResultContainer.CreatePassResult(status); + } + } + + public async Task 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."); + } + } } diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index dd2f318..9efb6ab 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -55,4 +55,16 @@ public interface ICmsTrackService long id, string trackName, string artist, string? album, string? genre, DateOnly? releaseDate, CancellationToken ct = default); + + /// + /// Fetch per-track waveform profile status from GET api/track/waveform-status for the + /// CMS PreProcessing panel. Unpaged — the admin catalogue is small. + /// + Task> GetWaveformStatusAsync(CancellationToken ct = default); + + /// + /// Trigger waveform profile generation for a single track via + /// POST api/track/{entryKey}/waveform. Maps a 404 to a "Track audio not found." failure. + /// + Task GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default); } diff --git a/DeepDrftModels/DTOs/WaveformStatusDto.cs b/DeepDrftModels/DTOs/WaveformStatusDto.cs new file mode 100644 index 0000000..4dc32ce --- /dev/null +++ b/DeepDrftModels/DTOs/WaveformStatusDto.cs @@ -0,0 +1,15 @@ +namespace DeepDrftModels.DTOs; + +/// +/// 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. is the existence check; is the +/// vault key used to trigger generation for a missing profile. +/// +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; } +}