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
@@ -431,6 +431,22 @@ public class TrackController : ControllerBase
return StatusCode(500, error);
}
// DELETE api/track/release/{id} ([ApiKeyAuthorize])
// Soft-delete a release row directly. Used by the albums browser to remove an orphaned release
// (one with no live tracks). "release" is a literal segment, declared here in the literal-route
// block so it never resolves to the parameterized "{trackId}" GET.
[ApiKeyAuthorize]
[HttpDelete("release/{id:long}")]
public async Task<ActionResult> DeleteRelease(long id, CancellationToken cancellationToken)
{
var result = await _sqlTrackService.DeleteRelease(id, cancellationToken);
if (result.Success) return Ok();
var error = result.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogError("DeleteRelease failed for id {Id}: {Error}", id, error);
return StatusCode(500, error);
}
// --- Parameterized routes ---
[HttpGet("{trackId}")]
@@ -155,6 +155,7 @@ public class UnifiedTrackService
}
var entryKey = lookup.Value.EntryKey;
var releaseId = lookup.Value.ReleaseId;
var sqlDelete = await _sqlTrackService.Delete(id);
if (!sqlDelete.Success)
@@ -164,6 +165,14 @@ public class UnifiedTrackService
return Result.CreateFailResult("Failed to delete track.");
}
// Cascade: if this was the last live track on its release, soft-delete the release too so it
// does not linger as a 0-track orphan in the albums browser. Non-fatal — the track delete
// already succeeded, so any failure here is logged and swallowed, not surfaced to the caller.
if (releaseId is { } rid)
{
await TrySoftDeleteEmptyReleaseAsync(rid, ct);
}
// Tri-state per FileDatabase's error-swallow contract: null = vault missing/error,
// false = entry not present, true = removed. Anything but a clean removal is an orphan.
var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
@@ -176,4 +185,30 @@ public class UnifiedTrackService
return Result.CreatePassResult();
}
// Soft-delete the release only when no live tracks remain on it. Best-effort: a count or delete
// failure here never fails the track delete that triggered it — it is logged so an orphaned
// release can be cleaned up later (the migration backfill also catches pre-existing orphans).
private async Task TrySoftDeleteEmptyReleaseAsync(long releaseId, CancellationToken ct)
{
var countResult = await _sqlTrackService.CountLiveTracksByRelease(releaseId, ct);
if (!countResult.Success)
{
var error = countResult.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogWarning("DeleteAsync: live-track count failed for release {ReleaseId}: {Error}", releaseId, error);
return;
}
if (countResult.Value > 0)
{
return;
}
var releaseDelete = await _sqlTrackService.DeleteRelease(releaseId, ct);
if (!releaseDelete.Success)
{
var error = releaseDelete.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogWarning("DeleteAsync: release soft-delete failed for {ReleaseId}: {Error}", releaseId, error);
}
}
}