Merge branch 'p8-w6-stale-refresh-album-delete' into dev
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,13 @@ public interface ITrackService
|
||||
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
||||
Task<ResultContainer<TrackDto>> Update(TrackDto track);
|
||||
Task<Result> Delete(long id);
|
||||
|
||||
/// <summary>Soft-delete a release row by id. Idempotent — a missing or already-deleted row is a no-op.</summary>
|
||||
Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Data-only migration: no schema change, snapshot unchanged.
|
||||
public partial class SoftDeleteOrphanedReleases : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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
|
||||
)
|
||||
);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("-- no-op: orphaned release soft-deletes are not rolled back");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,23 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
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<ReleaseEntity>()
|
||||
.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<int> 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
|
||||
|
||||
@@ -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<Result> 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<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await Repository.CountLiveTracksByReleaseAsync(releaseId, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user