From f02974b3c20eeebcf8534d41f1fd409eab918d61 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 17:56:18 -0400 Subject: [PATCH] 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) --- DeepDrftAPI/Controllers/TrackController.cs | 16 ++ DeepDrftAPI/Services/UnifiedTrackService.cs | 35 ++++ DeepDrftData/ITrackService.cs | 9 + ...000_SoftDeleteOrphanedReleases.Designer.cs | 178 ++++++++++++++++++ ...260612000000_SoftDeleteOrphanedReleases.cs | 40 ++++ DeepDrftData/Repositories/TrackRepository.cs | 17 ++ DeepDrftData/TrackManager.cs | 26 +++ .../Components/Pages/Tracks/BatchEdit.razor | 6 + .../Pages/Tracks/CmsAlbumBrowser.razor | 40 +++- .../Components/Pages/Tracks/TrackEdit.razor | 7 + .../Components/Pages/Tracks/TrackList.razor | 10 +- .../Services/CmsTrackBrowserViewModel.cs | 11 ++ DeepDrftManager/Services/CmsTrackService.cs | 33 ++++ DeepDrftManager/Services/ICmsTrackService.cs | 6 + 14 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 DeepDrftData/Migrations/20260612000000_SoftDeleteOrphanedReleases.Designer.cs create mode 100644 DeepDrftData/Migrations/20260612000000_SoftDeleteOrphanedReleases.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 98b7d6d..2a6b247 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -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 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}")] diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index 2949e25..9e565ca 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -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); + } + } } diff --git a/DeepDrftData/ITrackService.cs b/DeepDrftData/ITrackService.cs index 6cd1262..007b512 100644 --- a/DeepDrftData/ITrackService.cs +++ b/DeepDrftData/ITrackService.cs @@ -39,4 +39,13 @@ public interface ITrackService Task> Create(TrackDto newTrack); Task> Update(TrackDto track); Task Delete(long id); + + /// Soft-delete a release row by id. Idempotent — a missing or already-deleted row is a no-op. + Task DeleteRelease(long id, CancellationToken cancellationToken = default); + + /// + /// Count of non-deleted tracks on a release. Backs the delete-cascade decision: when a track + /// delete leaves a release with zero live tracks, the release is soft-deleted too. + /// + Task> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default); } diff --git a/DeepDrftData/Migrations/20260612000000_SoftDeleteOrphanedReleases.Designer.cs b/DeepDrftData/Migrations/20260612000000_SoftDeleteOrphanedReleases.Designer.cs new file mode 100644 index 0000000..92bef4c --- /dev/null +++ b/DeepDrftData/Migrations/20260612000000_SoftDeleteOrphanedReleases.Designer.cs @@ -0,0 +1,178 @@ +// +using System; +using DeepDrftData.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + [DbContext(typeof(DeepDrftContext))] + [Migration("20260612000000_SoftDeleteOrphanedReleases")] + partial class SoftDeleteOrphanedReleases + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Artist") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("artist"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint") + .HasColumnName("created_by_user_id"); + + b.Property("Genre") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("genre"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_path"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("ReleaseType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Single") + .HasColumnName("release_type"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_release_is_deleted"); + + b.HasIndex("Title", "Artist") + .IsUnique() + .HasDatabaseName("IX_release_title_artist"); + + b.ToTable("release", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("OriginalFileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("TrackName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("track_name"); + + b.Property("TrackNumber") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("track_number"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_track_is_deleted"); + + b.HasIndex("ReleaseId"); + + b.ToTable("track", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithMany("Tracks") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Navigation("Tracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeepDrftData/Migrations/20260612000000_SoftDeleteOrphanedReleases.cs b/DeepDrftData/Migrations/20260612000000_SoftDeleteOrphanedReleases.cs new file mode 100644 index 0000000..5fbdcb6 --- /dev/null +++ b/DeepDrftData/Migrations/20260612000000_SoftDeleteOrphanedReleases.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + // Data-only migration: no schema change, snapshot unchanged. + public partial class SoftDeleteOrphanedReleases : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Backfill: soft-delete any live release whose tracks were all soft-deleted before the + // delete-cascade in UnifiedTrackService existed. These show as 0-track rows in the albums + // browser; this clears the pre-existing orphans the cascade now prevents going forward. + migrationBuilder.Sql(@" +UPDATE release +SET is_deleted = true, + updated_at = now() +WHERE id IN ( + SELECT r.id + FROM release r + WHERE r.is_deleted = false + AND NOT EXISTS ( + SELECT 1 + FROM track t + WHERE t.release_id = r.id + AND t.is_deleted = false + ) +);"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("-- no-op: orphaned release soft-deletes are not rolled back"); + } + } +} diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index b39d33a..eafd155 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -182,6 +182,23 @@ public class TrackRepository : Repository await _context.SaveChangesAsync(ct); } + // Soft-delete a release row in a single set-based UPDATE (no load round-trip). The !IsDeleted + // guard makes a repeat call a no-op rather than re-stamping updated_at on an already-deleted row. + public async Task SoftDeleteReleaseAsync(long id, CancellationToken ct = default) + { + await _context.Set() + .Where(r => r.Id == id && !r.IsDeleted) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.IsDeleted, true) + .SetProperty(r => r.UpdatedAt, DateTime.UtcNow), ct); + } + + // Count of non-deleted tracks on a single release. Backs the delete-cascade decision in + // UnifiedTrackService: a release with zero live tracks after a delete is soft-deleted too. + // Uses Query (soft-delete filtered) so just-deleted tracks are excluded from the count. + public async Task CountLiveTracksByReleaseAsync(long releaseId, CancellationToken ct = default) + => await Query.CountAsync(t => t.ReleaseId == releaseId, ct); + protected override void UpdateEntity(TrackEntity target, TrackEntity source) { base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index 54ea6ae..d2bd070 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -282,4 +282,30 @@ public class TrackManager // Delete(long) → Result is inherited from Manager<> and satisfies ITrackService.Delete // by signature. No override. + + public async Task DeleteRelease(long id, CancellationToken cancellationToken = default) + { + try + { + await Repository.SoftDeleteReleaseAsync(id, cancellationToken); + return Result.CreatePassResult(); + } + catch (Exception e) + { + return Result.CreateFailResult(e.Message); + } + } + + public async Task> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default) + { + try + { + var count = await Repository.CountLiveTracksByReleaseAsync(releaseId, cancellationToken); + return ResultContainer.CreatePassResult(count); + } + catch (Exception e) + { + return ResultContainer.CreateFailResult(e.Message); + } + } } diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index b993a56..baf0df9 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -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); diff --git a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor index 2438673..df4374c 100644 --- a/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor +++ b/DeepDrftManager/Components/Pages/Tracks/CmsAlbumBrowser.razor @@ -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( + $"{WebUtility.HtmlEncode(row.Release.Title)} 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(); } diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor index 2be6c06..54372ca 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackEdit.razor @@ -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"); } diff --git a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor index 6c0c9a4..5a7da25 100644 --- a/DeepDrftManager/Components/Pages/Tracks/TrackList.razor +++ b/DeepDrftManager/Components/Pages/Tracks/TrackList.razor @@ -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; diff --git a/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs b/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs index 8f8e368..5a65c2f 100644 --- a/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs +++ b/DeepDrftManager/Services/CmsTrackBrowserViewModel.cs @@ -70,4 +70,15 @@ public class CmsTrackBrowserViewModel { ExpandedGenre = ExpandedGenre == genre ? null : genre; } + + /// + /// Drop the cached album and genre datasets so the next 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. + /// + public void Invalidate() + { + Albums = Array.Empty(); + Genres = Array.Empty(); + } } diff --git a/DeepDrftManager/Services/CmsTrackService.cs b/DeepDrftManager/Services/CmsTrackService.cs index e2f38c7..a43e0c4 100644 --- a/DeepDrftManager/Services/CmsTrackService.cs +++ b/DeepDrftManager/Services/CmsTrackService.cs @@ -152,6 +152,39 @@ public class CmsTrackService : ICmsTrackService } } + public async Task 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>> GetPagedAsync( int page, int pageSize, string? sortColumn, bool sortDescending, string? album = null, string? genre = null, diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index f407b9f..6362f94 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -40,6 +40,12 @@ public interface ICmsTrackService /// Task DeleteTrackAsync(long id, CancellationToken ct = default); + /// + /// 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. + /// + Task DeleteReleaseAsync(long releaseId, CancellationToken ct = default); + /// /// Fetch a page of track metadata from the Content API's GET api/track/page. Optional /// and filters narrow the result to a single