refactor(manager): replace internal CMS HTTP layer with direct CmsTrackService calls
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
@page "/cms/tracks/{Id:int}"
|
||||
@using System.Net.Http.Headers
|
||||
@using System.Net.Http.Json
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [HierarchicalRoleAuthorize([SystemRoleConstants.Admin])]
|
||||
@inject ITrackService TrackService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject IAuthSession AuthSession
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@@ -126,27 +124,32 @@
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var http = HttpClientFactory.CreateClient("DeepDrft.API");
|
||||
await AttachBearerAsync(http);
|
||||
|
||||
var payload = new
|
||||
// Re-fetch under the current scope so we mutate the DB-authoritative entity, not
|
||||
// the copy loaded at OnInitialized. Metadata-only update — EntryKey is immutable.
|
||||
var lookup = await TrackService.GetById(Id);
|
||||
if (!lookup.Success || lookup.Value is null)
|
||||
{
|
||||
TrackName = _form.TrackName,
|
||||
Artist = _form.Artist,
|
||||
Album = string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
|
||||
Genre = string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
|
||||
ReleaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null
|
||||
};
|
||||
Snackbar.Add("Save failed — track could not be loaded.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await http.PutAsJsonAsync($"api/cms/track/{Id}", payload);
|
||||
if (response.IsSuccessStatusCode)
|
||||
var track = lookup.Value;
|
||||
track.TrackName = _form.TrackName;
|
||||
track.Artist = _form.Artist;
|
||||
track.Album = string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album;
|
||||
track.Genre = string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre;
|
||||
track.ReleaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : null;
|
||||
|
||||
var updated = await TrackService.Update(track);
|
||||
if (updated.Success)
|
||||
{
|
||||
Snackbar.Add("Track updated.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Save failed: {(int)response.StatusCode} {response.ReasonPhrase}", Severity.Error);
|
||||
var error = updated.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Save failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -160,7 +163,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE api/cms/track/{Id} is handled by CmsDeleteController (T3 branch).
|
||||
private async Task ConfirmDelete()
|
||||
{
|
||||
if (_track is null) return;
|
||||
@@ -176,18 +178,16 @@
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var http = HttpClientFactory.CreateClient("DeepDrft.API");
|
||||
await AttachBearerAsync(http);
|
||||
|
||||
var response = await http.DeleteAsync($"api/cms/track/{Id}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
var result = await CmsTrackService.DeleteTrackAsync(Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add("Track deleted.", Severity.Success);
|
||||
Nav.NavigateTo("/cms/tracks");
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Delete failed: {(int)response.StatusCode} {response.ReasonPhrase}", Severity.Error);
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
@@ -199,15 +199,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AttachBearerAsync(HttpClient http)
|
||||
{
|
||||
var token = await AuthSession.GetValidTokenAsync();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackEditForm
|
||||
{
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
@page "/cms/tracks"
|
||||
@using System.Net
|
||||
@using System.Net.Http.Headers
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [HierarchicalRoleAuthorize([SystemRoleConstants.Admin])]
|
||||
@inject ITrackService TrackService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject IAuthSession AuthSession
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackList> Logger
|
||||
@@ -113,20 +112,16 @@
|
||||
|
||||
try
|
||||
{
|
||||
var client = HttpClientFactory.CreateClient("DeepDrft.API");
|
||||
var token = await AuthSession.GetValidTokenAsync();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await client.DeleteAsync($"api/cms/track/{track.Id}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
||||
if (_table is not null) await _table.ReloadServerData();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Delete failed ({(int)response.StatusCode} {response.ReasonPhrase}).", Severity.Error);
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
@page "/cms/tracks/new"
|
||||
@using System.Net.Http.Headers
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [HierarchicalRoleAuthorize([SystemRoleConstants.Admin])]
|
||||
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackNew> Logger
|
||||
@inject IAuthSession AuthSession
|
||||
|
||||
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
@@ -61,8 +62,8 @@
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches the proxy controller's RequestSizeLimit; the actual streaming
|
||||
// path means the limit caps the request, not in-memory buffering.
|
||||
// 1 GB ceiling matches DeepDrftContent's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
private IBrowserFile? _selectedFile;
|
||||
@@ -115,41 +116,46 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// The page is gated by [HierarchicalRoleAuthorize(Admin)], so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
using var multipart = new MultipartFormDataContent();
|
||||
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit;
|
||||
// wrapping in StreamContent avoids materialising the whole file in memory
|
||||
// before the proxy controller receives it.
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in StreamContent so the whole file is never materialised in
|
||||
// memory before DeepDrftContent receives it.
|
||||
await using var fileStream = _selectedFile.OpenReadStream(MaxUploadBytes);
|
||||
var fileContent = new StreamContent(fileStream);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(_selectedFile.ContentType) ? "audio/wav" : _selectedFile.ContentType);
|
||||
multipart.Add(fileContent, "wav", _selectedFile.Name);
|
||||
multipart.Add(new StringContent(_trackName), "trackName");
|
||||
multipart.Add(new StringContent(_artist), "artist");
|
||||
if (!string.IsNullOrWhiteSpace(_album)) multipart.Add(new StringContent(_album), "album");
|
||||
if (!string.IsNullOrWhiteSpace(_genre)) multipart.Add(new StringContent(_genre), "genre");
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)) multipart.Add(new StringContent(_releaseDate), "releaseDate");
|
||||
|
||||
var client = HttpClientFactory.CreateClient("DeepDrft.API");
|
||||
await AttachBearerAsync(client);
|
||||
using var response = await client.PostAsync("api/cms/track", multipart);
|
||||
var result = await CmsTrackService.UploadTrackAsync(
|
||||
fileStream,
|
||||
_selectedFile.Name,
|
||||
_selectedFile.ContentType,
|
||||
_trackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
createdByUserId);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
|
||||
Navigation.NavigateTo("/cms/tracks");
|
||||
return;
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
_errorMessage = string.IsNullOrWhiteSpace(body)
|
||||
? $"Upload failed ({(int)response.StatusCode})."
|
||||
: $"Upload failed ({(int)response.StatusCode}): {body}";
|
||||
Logger.LogWarning("CMS upload rejected: {Status} {Body}", (int)response.StatusCode, body);
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Upload failed: {error}";
|
||||
Logger.LogWarning("CMS upload rejected: {Error}", error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -167,15 +173,6 @@
|
||||
Navigation.NavigateTo("/cms/tracks");
|
||||
}
|
||||
|
||||
private async Task AttachBearerAsync(HttpClient http)
|
||||
{
|
||||
var token = await AuthSession.GetValidTokenAsync();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
const long KB = 1024;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Headers
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject IAuthSession AuthSession
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body1">
|
||||
Are you sure you want to delete '@TrackName'? This cannot be undone.
|
||||
</MudText>
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-3" Dense="true">@_errorMessage</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel" Disabled="_isDeleting">Cancel</MudButton>
|
||||
<MudButton Color="Color.Error"
|
||||
Variant="Variant.Filled"
|
||||
OnClick="ConfirmAsync"
|
||||
Disabled="_isDeleting">
|
||||
@if (_isDeleting)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="me-2" />
|
||||
<span>Deleting...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Delete</span>
|
||||
}
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
|
||||
|
||||
[Parameter] public long TrackId { get; set; }
|
||||
[Parameter] public string TrackName { get; set; } = "";
|
||||
[Parameter] public EventCallback OnDeleted { get; set; }
|
||||
|
||||
private bool _isDeleting;
|
||||
private string? _errorMessage;
|
||||
|
||||
private async Task ConfirmAsync()
|
||||
{
|
||||
_isDeleting = true;
|
||||
_errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var client = HttpClientFactory.CreateClient("DeepDrft.API");
|
||||
var token = await AuthSession.GetValidTokenAsync();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await client.DeleteAsync($"api/cms/track/{TrackId}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (OnDeleted.HasDelegate)
|
||||
{
|
||||
await OnDeleted.InvokeAsync();
|
||||
}
|
||||
MudDialog.Close(DialogResult.Ok(true));
|
||||
return;
|
||||
}
|
||||
|
||||
_errorMessage = response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.NotFound => "Track not found. It may have already been deleted.",
|
||||
System.Net.HttpStatusCode.Unauthorized => "You are not authorized to delete this track.",
|
||||
System.Net.HttpStatusCode.Forbidden => "You are not authorized to delete this track.",
|
||||
_ => $"Delete failed ({(int)response.StatusCode})."
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Delete failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using DeepDrftData;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// CMS delete endpoint. Owned by W3-T3 — separate controller from upload/edit to
|
||||
/// avoid merge contention with parallel CMS tracks.
|
||||
///
|
||||
/// Delete order (CMS-PLAN W1.5): SQL first, then vault. If the SQL row is gone we
|
||||
/// return success to the user even when the subsequent vault delete fails — SQL is
|
||||
/// the source of truth for "exists from the user's view". A vault failure is logged
|
||||
/// as an orphan for maintenance to reap (see PLAN.md §4.3 dead-letter).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/cms/track")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public class CmsDeleteController : ControllerBase
|
||||
{
|
||||
// Named HttpClient used to call DeepDrftContent's ApiKey-protected endpoints.
|
||||
// The Manager owns this name now that the CMS lives here; the client is registered
|
||||
// in Program.cs alongside the public "DeepDrft.API" client.
|
||||
private const string ContentCmsHttpClientName = "DeepDrft.Content.Cms";
|
||||
|
||||
private readonly ITrackService _trackService;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<CmsDeleteController> _logger;
|
||||
|
||||
public CmsDeleteController(
|
||||
ITrackService trackService,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<CmsDeleteController> logger)
|
||||
{
|
||||
_trackService = trackService;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpDelete("{id:long}")]
|
||||
public async Task<ActionResult> DeleteTrack(long id)
|
||||
{
|
||||
// 1. Resolve the EntryKey before we delete the SQL row — afterwards the join is gone.
|
||||
var lookup = await _trackService.GetById(id);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
_logger.LogError("CMS delete: lookup failed for track {TrackId}: {Error}", id, lookup.Messages.FirstOrDefault()?.Message);
|
||||
return StatusCode(500, "Failed to load track");
|
||||
}
|
||||
|
||||
var track = lookup.Value;
|
||||
if (track == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var entryKey = track.EntryKey;
|
||||
|
||||
// 2. SQL delete. On failure, do NOT touch the vault — nothing to clean up.
|
||||
var sqlDelete = await _trackService.Delete(id);
|
||||
if (!sqlDelete.Success)
|
||||
{
|
||||
_logger.LogError("CMS delete: SQL delete failed for track {TrackId}: {Error}", id, sqlDelete.Messages.FirstOrDefault()?.Message);
|
||||
return StatusCode(500, "Failed to delete track");
|
||||
}
|
||||
|
||||
// 3. Vault delete. Failure is logged as an orphan but does not fail the request:
|
||||
// SQL is the source of truth for the user's view; the orphan is a maintenance concern.
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsHttpClientName);
|
||||
try
|
||||
{
|
||||
var response = await client.DeleteAsync($"api/track/{Uri.EscapeDataString(entryKey)}");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Vault delete failed after SQL delete. {TrackId} {EntryKey} {StatusCode}",
|
||||
id, entryKey, (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Vault delete threw after SQL delete. {TrackId} {EntryKey}",
|
||||
id, entryKey);
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftManager.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Route("api/cms/track")]
|
||||
public class CmsEditController : ControllerBase
|
||||
{
|
||||
private readonly ITrackService _trackService;
|
||||
|
||||
public CmsEditController(ITrackService trackService)
|
||||
{
|
||||
_trackService = trackService;
|
||||
}
|
||||
|
||||
// Metadata-only update. EntryKey is immutable in Wave 1 — audio replacement
|
||||
// is a separate Wave 2 operation that touches the vault.
|
||||
[HttpPut("{id:long}")]
|
||||
public async Task<ActionResult<ApiResultDto<TrackEntity>>> Update(long id, [FromBody] CmsTrackUpdateRequest request)
|
||||
{
|
||||
var existing = await _trackService.GetById(id);
|
||||
if (!existing.Success)
|
||||
{
|
||||
var failure = ApiResult<TrackEntity>.CreateFailResult(existing.GetMessage());
|
||||
return StatusCode(500, new ApiResultDto<TrackEntity>(failure));
|
||||
}
|
||||
|
||||
if (existing.Value is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var track = existing.Value;
|
||||
track.TrackName = request.TrackName;
|
||||
track.Artist = request.Artist;
|
||||
track.Album = request.Album;
|
||||
track.Genre = request.Genre;
|
||||
track.ReleaseDate = request.ReleaseDate;
|
||||
|
||||
var updated = await _trackService.Update(track);
|
||||
var apiResult = ApiResult<TrackEntity>.From(updated);
|
||||
var dto = new ApiResultDto<TrackEntity>(apiResult);
|
||||
|
||||
return updated.Success ? Ok(dto) : StatusCode(500, dto);
|
||||
}
|
||||
}
|
||||
|
||||
public record CmsTrackUpdateRequest(
|
||||
[Required, MaxLength(200)] string TrackName,
|
||||
[Required, MaxLength(200)] string Artist,
|
||||
[MaxLength(200)] string? Album,
|
||||
[MaxLength(100)] string? Genre,
|
||||
DateOnly? ReleaseDate);
|
||||
@@ -1,168 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftManager.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// CMS upload surface. Proxies a WAV + metadata multipart form to DeepDrftContent's
|
||||
/// POST api/track/upload, then persists the returned unpersisted TrackEntity to SQL via
|
||||
/// ITrackService.Create. DeepDrftManager intentionally does not reference DeepDrftContent.Data
|
||||
/// (CMS-PLAN §5, Option B) — all vault access is over HTTP.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Route("api/cms")]
|
||||
public class CmsUploadController : ControllerBase
|
||||
{
|
||||
private const string ContentClientName = "DeepDrft.Content";
|
||||
private const string UploadPath = "api/track/upload";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ITrackService _trackService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<CmsUploadController> _logger;
|
||||
|
||||
public CmsUploadController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ITrackService trackService,
|
||||
IConfiguration configuration,
|
||||
ILogger<CmsUploadController> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_trackService = trackService;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Match DeepDrftContent's per-request ceiling so the proxy itself does not reject
|
||||
// a payload the downstream endpoint would accept.
|
||||
[HttpPost("track")]
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
public async Task<ActionResult<TrackEntity>> UploadTrack(
|
||||
[FromForm] IFormFile? wav,
|
||||
[FromForm] string? trackName,
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
[FromForm] string? genre,
|
||||
[FromForm] string? releaseDate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (wav is null || wav.Length == 0)
|
||||
{
|
||||
return BadRequest("WAV file is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trackName))
|
||||
{
|
||||
return BadRequest("trackName is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
return BadRequest("artist is required");
|
||||
}
|
||||
|
||||
var apiKey = _configuration["DeepDrftContent:ApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogError("DeepDrftContent:ApiKey is not configured");
|
||||
return StatusCode(500, "Content API key is not configured");
|
||||
}
|
||||
|
||||
var userIdValue = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var userId))
|
||||
{
|
||||
// [Authorize(Roles = "Admin")] gates upstream, so a missing/unparseable
|
||||
// user id here is a configuration bug, not a normal client state.
|
||||
_logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
return StatusCode(500, "Authenticated user is missing a valid identifier");
|
||||
}
|
||||
|
||||
// Forward the upload to DeepDrftContent. We rebuild the multipart container rather
|
||||
// than relaying Request.Body so the boundary is owned by HttpClient and IFormFile's
|
||||
// already-buffered stream (memory + temp-file backed by Kestrel) is the source.
|
||||
using var multipart = new MultipartFormDataContent();
|
||||
await using var wavStream = wav.OpenReadStream();
|
||||
var wavContent = new StreamContent(wavStream);
|
||||
wavContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(wav.ContentType) ? "audio/wav" : wav.ContentType);
|
||||
multipart.Add(wavContent, "wav", wav.FileName);
|
||||
multipart.Add(new StringContent(trackName), "trackName");
|
||||
multipart.Add(new StringContent(artist), "artist");
|
||||
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
|
||||
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
|
||||
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
|
||||
request.Headers.Add("ApiKey", apiKey);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for upload of {TrackName}", trackName);
|
||||
return StatusCode(502, "Content API is unreachable");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
if (statusCode >= 500)
|
||||
{
|
||||
_logger.LogError("Content API returned {Status} for upload of {TrackName}: {Body}", statusCode, trackName, body);
|
||||
return StatusCode(statusCode, "Upload failed on the content server. Please try again.");
|
||||
}
|
||||
|
||||
// 4xx: body is user-friendly validation text from DeepDrftContent — relay as-is.
|
||||
_logger.LogWarning("Content API rejected upload: {Status} {Body}", statusCode, body);
|
||||
return StatusCode(statusCode, body);
|
||||
}
|
||||
|
||||
TrackEntity? unpersisted;
|
||||
try
|
||||
{
|
||||
unpersisted = await response.Content.ReadFromJsonAsync<TrackEntity>(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize TrackEntity from Content API response");
|
||||
return StatusCode(502, "Content API returned an unexpected response");
|
||||
}
|
||||
|
||||
if (unpersisted is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null TrackEntity");
|
||||
return StatusCode(502, "Content API returned an empty response");
|
||||
}
|
||||
|
||||
unpersisted.CreatedByUserId = userId;
|
||||
|
||||
var saveResult = await _trackService.Create(unpersisted);
|
||||
if (!saveResult.Success || saveResult.Value is null)
|
||||
{
|
||||
// The vault write succeeded but the SQL persist failed — audio is now orphaned
|
||||
// in the tracks vault under EntryKey. CMS-PLAN W2.4 covers the dead-letter
|
||||
// mechanism; until then we log loudly so the orphan is recoverable manually.
|
||||
var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}",
|
||||
unpersisted.EntryKey, error);
|
||||
return StatusCode(500, $"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
return Ok(saveResult.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-23
@@ -4,6 +4,7 @@ using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftManager.Components;
|
||||
using DeepDrftManager.Services;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MudBlazor.Services;
|
||||
@@ -16,8 +17,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
// - environment/apikey.json: { "DeepDrftContent": { "ApiKey": "..." } }
|
||||
// - environment/connections.json: { "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }
|
||||
// - environment/authblocks.json: { "AuthBlocks": { "Jwt": {...}, "Email": {...}, "Admin": {...} } }
|
||||
// Content API key — not consumed by this host in Phase 1. Required by the CredentialTools
|
||||
// pattern (the file must exist); will be used by CmsUploadController when it migrates here.
|
||||
// Content API key — consumed by CmsTrackService for the upload proxy and the vault-delete client.
|
||||
var apiKeyPath = CredentialTools.ResolvePathOrThrow("apikey", "environment/apikey.json");
|
||||
builder.Configuration.AddJsonFile(apiKeyPath, optional: false, reloadOnChange: false);
|
||||
|
||||
@@ -40,6 +40,11 @@ builder.Services
|
||||
.AddScoped<TrackManager>()
|
||||
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
|
||||
// CMS track mutations (upload proxy + delete). Called directly by the InteractiveServer
|
||||
// Blazor components — no in-process HTTP roundtrip. Vault access still goes over HTTP to
|
||||
// DeepDrftContent via the named clients below.
|
||||
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
||||
|
||||
// AuthBlocks: JWT Bearer auth, Identity, EF schema, admin seeding.
|
||||
// Auth schema runs in its own database (separate from DefaultConnection by design).
|
||||
builder.Services.AddAuthBlocks(options =>
|
||||
@@ -77,17 +82,8 @@ builder.Services.AddAuthBlocks(options =>
|
||||
var baseUrl = GetKestrelUrl(builder);
|
||||
AuthBlocksWeb.Startup.ConfigureAuthServices(builder.Services, baseUrl);
|
||||
|
||||
// Named HttpClient used by CMS pages for in-process CMS endpoints (CmsUploadController,
|
||||
// CmsEditController, CmsDeleteController) and the AuthBlocks surface — both live on this host.
|
||||
// Base-addressed to the Manager's own Kestrel URL so callers using relative paths
|
||||
// (e.g. "api/cms/track") hit our own controllers, not the public host.
|
||||
builder.Services.AddHttpClient("DeepDrft.API", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
|
||||
// Named HttpClient for unauthenticated Content API calls (e.g. CmsUploadController proxying WAV
|
||||
// data to DeepDrftContent's POST api/track/upload). API key added per-request by the controller.
|
||||
// Named HttpClient for unauthenticated Content API calls (CmsTrackService proxying WAV data
|
||||
// to DeepDrftContent's POST api/track/upload). API key added per-request by the service.
|
||||
var contentApiUrl = builder.Configuration["ApiUrls:ContentApi"]
|
||||
?? throw new InvalidOperationException("ApiUrls:ContentApi is required");
|
||||
builder.Services.AddHttpClient("DeepDrft.Content", client =>
|
||||
@@ -95,8 +91,8 @@ builder.Services.AddHttpClient("DeepDrft.Content", client =>
|
||||
client.BaseAddress = new Uri(contentApiUrl);
|
||||
});
|
||||
|
||||
// Named HttpClient for ApiKey-protected Content API calls (e.g. CmsDeleteController's vault
|
||||
// delete). API key baked into the default request headers so callers need not add it manually.
|
||||
// Named HttpClient for ApiKey-protected Content API calls (CmsTrackService's vault delete).
|
||||
// API key baked into the default request headers so callers need not add it manually.
|
||||
var contentApiKey = builder.Configuration["DeepDrftContent:ApiKey"]
|
||||
?? throw new InvalidOperationException("DeepDrftContent:ApiKey is required");
|
||||
builder.Services.AddHttpClient("DeepDrft.Content.Cms", client =>
|
||||
@@ -116,10 +112,6 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
// Controllers: discovers CMS mutation controllers (CmsUploadController, CmsEditController,
|
||||
// CmsDeleteController) and the AuthBlocks surface. Matches DeepDrftPublic precedent.
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// InteractiveServer only — no WASM render mode on the CMS host.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
@@ -165,16 +157,13 @@ app.MapStaticAssets();
|
||||
// Razor pages (/account/login, /account/logout).
|
||||
app.MapAuthBlocks();
|
||||
|
||||
// Mounts CMS mutation controllers (CmsUploadController, CmsEditController, CmsDeleteController).
|
||||
app.MapControllers();
|
||||
|
||||
// Blazor page authorization is owned by AuthorizeRouteView in Routes.razor, not
|
||||
// ASP.NET Core endpoint authorization. AuthBlocks tokens live in browser localStorage
|
||||
// (read via JS interop by JwtAuthenticationStateProvider), so the JWT never reaches
|
||||
// the server on a navigation request. Without AllowAnonymous here, the JwtBearer
|
||||
// challenge for an unauthenticated nav returns 401 before the Blazor router runs,
|
||||
// short-circuiting the NotAuthorized -> RedirectToLogin path. JWT enforcement
|
||||
// remains in force for the API surfaces (MapAuthBlocks, MapControllers).
|
||||
// remains in force for the AuthBlocks API surface (MapAuthBlocks).
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(AuthBlocksWeb._Imports).Assembly)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Net.Http.Headers;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.Entities;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Direct-call CMS track service. Replaces the former in-process HTTP roundtrip through
|
||||
/// CmsUploadController / CmsDeleteController: the Manager is InteractiveServer-only, so its
|
||||
/// Blazor components inject this service and call it directly rather than POSTing to their
|
||||
/// own loopback controllers. Vault access remains over HTTP to DeepDrftContent (a separate
|
||||
/// host); SQL metadata is reached directly via <see cref="ITrackService"/>.
|
||||
/// </summary>
|
||||
public class CmsTrackService : ICmsTrackService
|
||||
{
|
||||
private const string ContentClientName = "DeepDrft.Content";
|
||||
private const string ContentCmsClientName = "DeepDrft.Content.Cms";
|
||||
private const string UploadPath = "api/track/upload";
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ITrackService _trackService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<CmsTrackService> _logger;
|
||||
|
||||
public CmsTrackService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ITrackService trackService,
|
||||
IConfiguration configuration,
|
||||
ILogger<CmsTrackService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_trackService = trackService;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<TrackEntity>> UploadTrackAsync(
|
||||
Stream wavStream,
|
||||
string fileName,
|
||||
string contentType,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? releaseDate,
|
||||
long createdByUserId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var apiKey = _configuration["DeepDrftContent:ApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogError("DeepDrftContent:ApiKey is not configured");
|
||||
return ResultContainer<TrackEntity>.CreateFailResult("Content API key is not configured.");
|
||||
}
|
||||
|
||||
// Rebuild the multipart container so the boundary is owned by HttpClient and the
|
||||
// caller-supplied stream (already buffered by the SignalR upload) is the source.
|
||||
using var multipart = new MultipartFormDataContent();
|
||||
var wavContent = new StreamContent(wavStream);
|
||||
wavContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType);
|
||||
multipart.Add(wavContent, "wav", fileName);
|
||||
multipart.Add(new StringContent(trackName), "trackName");
|
||||
multipart.Add(new StringContent(artist), "artist");
|
||||
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
|
||||
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
|
||||
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
|
||||
request.Headers.Add("ApiKey", apiKey);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for upload of {TrackName}", trackName);
|
||||
return ResultContainer<TrackEntity>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
if (statusCode >= 500)
|
||||
{
|
||||
_logger.LogError("Content API returned {Status} for upload of {TrackName}: {Body}", statusCode, trackName, body);
|
||||
return ResultContainer<TrackEntity>.CreateFailResult("Upload failed on the content server. Please try again.");
|
||||
}
|
||||
|
||||
// 4xx: body is user-friendly validation text from DeepDrftContent — relay as-is.
|
||||
_logger.LogWarning("Content API rejected upload: {Status} {Body}", statusCode, body);
|
||||
return ResultContainer<TrackEntity>.CreateFailResult(
|
||||
string.IsNullOrWhiteSpace(body) ? $"Upload rejected ({statusCode})." : body);
|
||||
}
|
||||
|
||||
TrackEntity? unpersisted;
|
||||
try
|
||||
{
|
||||
unpersisted = await response.Content.ReadFromJsonAsync<TrackEntity>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize TrackEntity from Content API response");
|
||||
return ResultContainer<TrackEntity>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (unpersisted is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null TrackEntity");
|
||||
return ResultContainer<TrackEntity>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
unpersisted.CreatedByUserId = createdByUserId;
|
||||
|
||||
var saveResult = await _trackService.Create(unpersisted);
|
||||
if (!saveResult.Success || saveResult.Value is null)
|
||||
{
|
||||
// The vault write succeeded but the SQL persist failed — audio is now orphaned
|
||||
// in the tracks vault under EntryKey. CMS-PLAN W2.4 covers the dead-letter
|
||||
// mechanism; until then we log loudly so the orphan is recoverable manually.
|
||||
var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}",
|
||||
unpersisted.EntryKey, error);
|
||||
return ResultContainer<TrackEntity>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
return saveResult;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default)
|
||||
{
|
||||
// 1. Resolve the EntryKey before we delete the SQL row — afterwards the join is gone.
|
||||
var lookup = await _trackService.GetById(id);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message;
|
||||
_logger.LogError("CMS delete: lookup failed for track {TrackId}: {Error}", id, error);
|
||||
return Result.CreateFailResult("Failed to load track.");
|
||||
}
|
||||
|
||||
var track = lookup.Value;
|
||||
if (track is null)
|
||||
{
|
||||
return Result.CreateFailResult("Track not found.");
|
||||
}
|
||||
|
||||
var entryKey = track.EntryKey;
|
||||
|
||||
// 2. SQL delete. On failure, do NOT touch the vault — nothing to clean up.
|
||||
var sqlDelete = await _trackService.Delete(id);
|
||||
if (!sqlDelete.Success)
|
||||
{
|
||||
var error = sqlDelete.Messages.FirstOrDefault()?.Message;
|
||||
_logger.LogError("CMS delete: SQL delete failed for track {TrackId}: {Error}", id, error);
|
||||
return Result.CreateFailResult("Failed to delete track.");
|
||||
}
|
||||
|
||||
// 3. Vault delete. Failure is logged as an orphan but does not fail the operation:
|
||||
// SQL is the source of truth for the user's view; the orphan is a maintenance concern.
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
try
|
||||
{
|
||||
using var response = await client.DeleteAsync($"api/track/{Uri.EscapeDataString(entryKey)}", ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Vault delete failed after SQL delete. {TrackId} {EntryKey} {StatusCode}",
|
||||
id, entryKey, (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Vault delete threw after SQL delete. {TrackId} {EntryKey}", id, entryKey);
|
||||
}
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CMS-side track mutations for the Manager host. Coordinates the dual-database write:
|
||||
/// SQL metadata via <c>ITrackService</c> and binary audio in DeepDrftContent's vault over HTTP.
|
||||
/// DeepDrftManager intentionally does not reference DeepDrftContent.Services (CMS-PLAN §5,
|
||||
/// Option B) — all vault access is over the network to DeepDrftContent.
|
||||
/// </summary>
|
||||
public interface ICmsTrackService
|
||||
{
|
||||
/// <summary>
|
||||
/// Proxy a WAV upload to DeepDrftContent, then persist the returned metadata to SQL.
|
||||
/// On success the returned entity carries the SQL-assigned <c>Id</c>. If the vault write
|
||||
/// succeeds but the SQL persist fails, the audio is orphaned under <c>EntryKey</c> — the
|
||||
/// failure is logged loudly and surfaced as a failed result.
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackEntity>> UploadTrackAsync(
|
||||
Stream wavStream,
|
||||
string fileName,
|
||||
string contentType,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? releaseDate,
|
||||
long createdByUserId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL
|
||||
/// delete failure fails the operation, but a subsequent vault delete failure is logged
|
||||
/// and swallowed (the orphan is a maintenance concern, not a user-facing error).
|
||||
/// </summary>
|
||||
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
|
||||
}
|
||||
Reference in New Issue
Block a user