Wave 3 T2: WAV upload flow — POST api/track/upload, POST api/cms/track, /cms/tracks/new
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
@page "/cms/tracks/new"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using System.Net.Http.Headers
|
||||||
|
@using AuthBlocksWeb.HierarchicalAuthorize
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.Extensions.Logging
|
||||||
|
@attribute [HierarchicalRoleAuthorize("Admin")]
|
||||||
|
|
||||||
|
@inject IHttpClientFactory HttpClientFactory
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject ILogger<TrackNew> Logger
|
||||||
|
|
||||||
|
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
||||||
|
<MudText Typo="Typo.h4" GutterBottom="true">Add Track</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-6" Elevation="2">
|
||||||
|
<MudStack Spacing="4">
|
||||||
|
<MudText Typo="Typo.subtitle1">WAV file</MudText>
|
||||||
|
<InputFile OnChange="OnFileSelected" accept=".wav,audio/wav,audio/x-wav" />
|
||||||
|
@if (_selectedFile is not null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size))
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudTextField @bind-Value="_trackName" Label="Track Name" Required="true" RequiredError="Track Name is required" Variant="Variant.Outlined" />
|
||||||
|
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
|
||||||
|
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
|
||||||
|
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
|
||||||
|
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error">@_errorMessage</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudStack Row="true" Spacing="2" Justify="Justify.FlexEnd">
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
OnClick="Cancel"
|
||||||
|
Disabled="_isUploading">
|
||||||
|
Cancel
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="SubmitAsync"
|
||||||
|
Disabled="_isUploading">
|
||||||
|
@if (_isUploading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||||
|
<text>Uploading…</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>Upload</text>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</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.
|
||||||
|
private const long MaxUploadBytes = 1_073_741_824L;
|
||||||
|
|
||||||
|
private IBrowserFile? _selectedFile;
|
||||||
|
private string _trackName = string.Empty;
|
||||||
|
private string _artist = string.Empty;
|
||||||
|
private string _album = string.Empty;
|
||||||
|
private string _genre = string.Empty;
|
||||||
|
private string _releaseDate = string.Empty;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private bool _isUploading;
|
||||||
|
|
||||||
|
private void OnFileSelected(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_selectedFile = e.File;
|
||||||
|
_errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitAsync()
|
||||||
|
{
|
||||||
|
_errorMessage = null;
|
||||||
|
|
||||||
|
if (_selectedFile is null)
|
||||||
|
{
|
||||||
|
_errorMessage = "Please select a WAV file.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_selectedFile.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_errorMessage = "Selected file must be a .wav file.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_trackName))
|
||||||
|
{
|
||||||
|
_errorMessage = "Track Name is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_artist))
|
||||||
|
{
|
||||||
|
_errorMessage = "Artist is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||||
|
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||||
|
{
|
||||||
|
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||||
|
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.
|
||||||
|
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");
|
||||||
|
using var response = await client.PostAsync("api/cms/track", multipart);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "CMS upload failed");
|
||||||
|
_errorMessage = $"Upload failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isUploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/cms/tracks");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatBytes(long bytes)
|
||||||
|
{
|
||||||
|
const long KB = 1024;
|
||||||
|
const long MB = KB * 1024;
|
||||||
|
const long GB = MB * 1024;
|
||||||
|
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
|
||||||
|
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
|
||||||
|
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
|
||||||
|
return $"{bytes} bytes";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ The binary content API host. ApiKey middleware, CORS, forwarded headers. Returns
|
|||||||
- `WavOffsetService` — in `DeepDrftContent.Services`.
|
- `WavOffsetService` — in `DeepDrftContent.Services`.
|
||||||
- Don't add new domain code to this project.
|
- Don't add new domain code to this project.
|
||||||
|
|
||||||
## The endpoint surface (exactly two endpoints)
|
## The endpoint surface (three endpoints)
|
||||||
|
|
||||||
### GET api/track/{trackId}?offset=0 (unauthenticated)
|
### GET api/track/{trackId}?offset=0 (unauthenticated)
|
||||||
|
|
||||||
@@ -45,7 +45,24 @@ Returns the WAV bytes from the `tracks` vault.
|
|||||||
- Actually: the endpoint is rarely used in production (the CLI calls `FileDatabase.RegisterResourceAsync` directly). But the endpoint exists for potential web-side uploads in future.
|
- Actually: the endpoint is rarely used in production (the CLI calls `FileDatabase.RegisterResourceAsync` directly). But the endpoint exists for potential web-side uploads in future.
|
||||||
- Returns 200 on success, 401 if ApiKey invalid, 400 if body invalid.
|
- Returns 200 on success, 401 if ApiKey invalid, 400 if body invalid.
|
||||||
|
|
||||||
**Do not add a third endpoint without product approval.** The surface is intentionally minimal.
|
### POST api/track/upload ([ApiKeyAuthorize])
|
||||||
|
|
||||||
|
**Authenticated endpoint.** Accepts a raw WAV upload + metadata as `multipart/form-data`, processes the WAV via `DeepDrftContent.Services.TrackService.AddTrackFromWavAsync`, and returns an unpersisted `TrackEntity` (no `Id` assigned). The caller (the CMS controller on `DeepDrftWeb`) is responsible for then saving that entity to SQL.
|
||||||
|
|
||||||
|
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||||
|
- **Form fields**:
|
||||||
|
- `wav` (`IFormFile`, required): the WAV bytes. File name must end in `.wav`.
|
||||||
|
- `trackName` (string, required)
|
||||||
|
- `artist` (string, required)
|
||||||
|
- `album` (string, optional)
|
||||||
|
- `genre` (string, optional)
|
||||||
|
- `releaseDate` (string, optional, format `YYYY-MM-DD`)
|
||||||
|
- The upload stream is copied to a `.wav`-suffixed temp file under `Path.GetTempPath()` (the audio processor requires that extension and reads from disk). The temp file is always deleted in a `finally` block — success or failure.
|
||||||
|
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized WAVs are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||||
|
- Returns 200 with the unpersisted `TrackEntity` JSON on success. Returns 400 for missing/invalid form fields (no WAV, missing required strings, wrong extension, bad date). Returns 500 if `AddTrackFromWavAsync` returns null or throws.
|
||||||
|
- **Wave 3 product approval covers this addition** (CMS-PLAN §5, Option B): `DeepDrftWeb` proxies CMS uploads here so it never touches the vault disk path. Do not extend the endpoint surface further without a fresh approval.
|
||||||
|
|
||||||
|
**The surface is intentionally minimal.** Do not add a fourth endpoint without product approval.
|
||||||
|
|
||||||
## ApiKey middleware behaviour
|
## ApiKey middleware behaviour
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,111 @@ public class TrackController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → unpersisted TrackEntity out.
|
||||||
|
// Used by the CMS upload flow on DeepDrftWeb; that host proxies the upload here so it never
|
||||||
|
// touches the vault disk path directly (Option B in CMS-PLAN §5).
|
||||||
|
//
|
||||||
|
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds
|
||||||
|
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
|
||||||
|
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
|
||||||
|
// not a buffered allocation.
|
||||||
|
[ApiKeyAuthorize]
|
||||||
|
[HttpPost("upload")]
|
||||||
|
[RequestSizeLimit(1_073_741_824)]
|
||||||
|
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||||
|
public async Task<ActionResult<DeepDrftModels.Entities.TrackEntity>> UploadTrack(
|
||||||
|
[FromForm] IFormFile? wav,
|
||||||
|
[FromForm] string? trackName,
|
||||||
|
[FromForm] string? artist,
|
||||||
|
[FromForm] string? album,
|
||||||
|
[FromForm] string? genre,
|
||||||
|
[FromForm] string? releaseDate)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}",
|
||||||
|
trackName, artist, wav?.Length);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return BadRequest("Uploaded file must have a .wav extension");
|
||||||
|
}
|
||||||
|
|
||||||
|
DateOnly? parsedReleaseDate = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(releaseDate))
|
||||||
|
{
|
||||||
|
if (!DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out var parsed))
|
||||||
|
{
|
||||||
|
return BadRequest("releaseDate must be in YYYY-MM-DD format");
|
||||||
|
}
|
||||||
|
parsedReleaseDate = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk.
|
||||||
|
// Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path.
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (var tempStream = new FileStream(
|
||||||
|
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||||
|
bufferSize: 81920, useAsync: true))
|
||||||
|
await using (var uploadStream = wav.OpenReadStream())
|
||||||
|
{
|
||||||
|
await uploadStream.CopyToAsync(tempStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = await _trackService.AddTrackFromWavAsync(
|
||||||
|
tempPath,
|
||||||
|
trackName,
|
||||||
|
artist,
|
||||||
|
string.IsNullOrWhiteSpace(album) ? null : album,
|
||||||
|
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
||||||
|
parsedReleaseDate);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("UploadTrack: TrackService returned null for {TrackName}", trackName);
|
||||||
|
return StatusCode(500, "Failed to process and store WAV");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("UploadTrack succeeded: entryKey={EntryKey}", entity.EntryKey);
|
||||||
|
return Ok(entity);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "UploadTrack failed for {TrackName}", trackName);
|
||||||
|
return StatusCode(500, "Internal server error");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (System.IO.File.Exists(tempPath))
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[ApiKeyAuthorize]
|
[ApiKeyAuthorize]
|
||||||
[HttpPut("{trackId}")]
|
[HttpPut("{trackId}")]
|
||||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using DeepDrftModels.Entities;
|
||||||
|
using DeepDrftWeb.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DeepDrftWeb.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. DeepDrftWeb intentionally does not reference DeepDrftContent.Services
|
||||||
|
/// (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["ContentApi:ApiKey"];
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
_logger.LogError("ContentApi: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);
|
||||||
|
_logger.LogWarning("Content API rejected upload: {Status} {Body}", (int)response.StatusCode, body);
|
||||||
|
return StatusCode((int)response.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,6 +12,9 @@
|
|||||||
"ApiUrls": {
|
"ApiUrls": {
|
||||||
"ContentApi": "http://localhost:12777/"
|
"ContentApi": "http://localhost:12777/"
|
||||||
},
|
},
|
||||||
|
"ContentApi": {
|
||||||
|
"ApiKey": "REPLACE_IN_ENV"
|
||||||
|
},
|
||||||
"ForwardedHeaders": {
|
"ForwardedHeaders": {
|
||||||
"DisableHttpsRedirection": "true"
|
"DisableHttpsRedirection": "true"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user