fix: refresh stale browse cache on track edits and allow deleting empty releases

- Add CmsTrackBrowserViewModel.Invalidate(); called from TrackEdit/BatchEdit on save or delete so album/genre cache is invalidated and re-fetches on next mode switch
- CmsAlbumBrowser now handles 0-track releases: confirm dialog + DeleteReleaseAsync instead of early return; partial-failure path also fires OnReleasesChanged to trigger cache invalidation
- TrackList.OnAlbumsChanged now calls VM.Invalidate() so genres stay fresh after any album delete
- UnifiedTrackService.DeleteAsync cascades release soft-delete when last live track is removed (non-fatal; logs on failure)
- New DELETE api/track/release/{id} endpoint (ApiKeyAuthorize) for direct release soft-delete
- EF migration SoftDeleteOrphanedReleases backfills existing orphaned release rows via raw SQL (data-only, no schema change)
This commit is contained in:
daniel-c-harvey
2026-06-11 17:56:18 -04:00
parent fd8c0e389f
commit f02974b3c2
14 changed files with 430 additions and 4 deletions
@@ -7,6 +7,7 @@
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject CmsTrackBrowserViewModel VM
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -447,6 +448,11 @@
StateHasChanged();
}
// Either branch changed catalogue data, so the browse caches are stale regardless of
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
// album and genre lists re-fetch.
VM.Invalidate();
if (failed == 0)
{
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
@@ -189,7 +189,9 @@ else
if (count == 0)
{
Snackbar.Add($"'{row.Release.Title}' has no tracks to delete.", Severity.Info);
// Orphaned release: every track was soft-deleted earlier, leaving a 0-track row that
// cannot be cleared by deleting tracks. Delete the release record directly instead.
await ConfirmAndDeleteEmptyReleaseAsync(row);
return;
}
@@ -231,6 +233,42 @@ else
else
{
Snackbar.Add($"{count - failures} of {count} track(s) deleted; {failures} failed.", Severity.Warning);
await OnReleasesChanged.InvokeAsync();
}
StateHasChanged();
}
// Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade
// delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the
// parent so the cached VM.Albums stays in sync with what is shown.
private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
{
var confirmed = await DialogService.ShowMessageBox(
title: "Delete release",
markupMessage: new MarkupString(
$"<strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong> has no tracks. Delete this empty release record?"),
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true) return;
row.IsDeleting = true;
StateHasChanged();
var result = await CmsTrackService.DeleteReleaseAsync(row.Release.Id);
row.IsDeleting = false;
if (result.Success)
{
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
_rows.Remove(row);
await OnReleasesChanged.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Delete failed: {error}", Severity.Error);
}
StateHasChanged();
}
@@ -4,6 +4,7 @@
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject CmsTrackBrowserViewModel VM
@inject IHttpClientFactory HttpClientFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -227,6 +228,9 @@
_form.TrackNumber);
if (updated.Success)
{
// Album/genre browse lists derive from this track's metadata; drop their cache so
// the /tracks browser re-fetches fresh data on next mode switch.
VM.Invalidate();
Snackbar.Add("Track updated.", Severity.Success);
await LoadAsync();
}
@@ -276,6 +280,9 @@
var result = await CmsTrackService.DeleteTrackAsync(Id);
if (result.Success)
{
// Deleting a track can empty or alter a release; drop the browse cache so the
// /tracks album and genre lists re-fetch fresh counts on next mode switch.
VM.Invalidate();
Snackbar.Add("Track deleted.", Severity.Success);
Nav.NavigateTo("/tracks");
}
@@ -69,9 +69,13 @@
@code {
private CmsTrackGrid? _grid;
// The album browser owns its own row state and removes a deleted release locally. We only need to
// re-render the page chrome; VM.Albums is intentionally not re-fetched (cached for the circuit).
private void OnAlbumsChanged() => StateHasChanged();
// The album browser owns its own row state and removes a deleted release locally. Invalidate the
// VM cache so genres and album counts reflect the deletion on next mode switch.
private void OnAlbumsChanged()
{
VM.Invalidate();
StateHasChanged();
}
// Local state for the parent-owned "Generate All Missing" bulk run.
private bool _bulkRunning;
@@ -70,4 +70,15 @@ public class CmsTrackBrowserViewModel
{
ExpandedGenre = ExpandedGenre == genre ? null : genre;
}
/// <summary>
/// Drop the cached album and genre datasets so the next <see cref="SwitchModeAsync"/> into
/// either mode re-fetches from the API. Call after a track or release mutation (edit, delete)
/// since both datasets are derived from the catalogue and go stale on any such change.
/// </summary>
public void Invalidate()
{
Albums = Array.Empty<ReleaseDto>();
Genres = Array.Empty<GenreSummaryDto>();
}
}
@@ -152,6 +152,39 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<Result> DeleteReleaseAsync(long releaseId, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.DeleteAsync($"api/track/release/{releaseId}", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for delete 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);
_logger.LogError("Content API delete failed for release {ReleaseId}: {Status} {Body}", releaseId, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to delete release.");
}
}
public async Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
string? album = null, string? genre = null,
@@ -40,6 +40,12 @@ public interface ICmsTrackService
/// </summary>
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
/// <summary>
/// Soft-delete a release record via DELETE api/track/release/{id}. Use when a release
/// has no live tracks and needs to be removed from the albums browser.
/// </summary>
Task<Result> DeleteReleaseAsync(long releaseId, CancellationToken ct = default);
/// <summary>
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>. Optional
/// <paramref name="album"/> and <paramref name="genre"/> filters narrow the result to a single