d7071fdbc2
Build the staging path before the copy in both UploadTrack and ReplaceAudio so the finally block deletes it on cancellation or IO error, not only on success.
828 lines
37 KiB
C#
828 lines
37 KiB
C#
using DeepDrftAPI.Middleware;
|
|
using DeepDrftAPI.Models;
|
|
using DeepDrftAPI.Services;
|
|
using DeepDrftContent.Constants;
|
|
using DeepDrftContent.FileDatabase.Services;
|
|
using DeepDrftContent.FileDatabase.Models;
|
|
using DeepDrftContent.Processors;
|
|
using DeepDrftData;
|
|
using DeepDrftModels.DTOs;
|
|
using DeepDrftModels.Enums;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace DeepDrftAPI.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/[controller]")]
|
|
public class TrackController : ControllerBase
|
|
{
|
|
private readonly DeepDrftContent.TrackContentService _trackContentService;
|
|
private readonly UnifiedTrackService _unifiedService;
|
|
private readonly ITrackService _sqlTrackService;
|
|
private readonly WaveformProfileService _waveformProfileService;
|
|
private readonly UploadStagingDirectory _stagingDirectory;
|
|
private readonly ILogger<TrackController> _logger;
|
|
|
|
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
|
|
// AudioBinaryDto over the wire, not a WAV file path. TrackContentService.AddTrackFromWavAsync is
|
|
// file-path-oriented and not applicable here. If a file-upload flow is added in future,
|
|
// route it through TrackContentService instead.
|
|
private readonly DeepDrftContent.FileDatabase.Services.FileDatabase _fileDatabase;
|
|
|
|
public TrackController(
|
|
DeepDrftContent.TrackContentService trackContentService,
|
|
DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase,
|
|
UnifiedTrackService unifiedService,
|
|
ITrackService sqlTrackService,
|
|
WaveformProfileService waveformProfileService,
|
|
UploadStagingDirectory stagingDirectory,
|
|
ILogger<TrackController> logger)
|
|
{
|
|
_trackContentService = trackContentService;
|
|
_fileDatabase = fileDatabase;
|
|
_unifiedService = unifiedService;
|
|
_sqlTrackService = sqlTrackService;
|
|
_waveformProfileService = waveformProfileService;
|
|
_stagingDirectory = stagingDirectory;
|
|
_logger = logger;
|
|
}
|
|
|
|
// Builds a unique staging file path on the data disk with the validated extension. The caller MUST
|
|
// assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
|
|
// way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
|
|
// partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
|
|
// on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
|
|
private string BuildStagingPath(string uploadExtension) =>
|
|
Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
|
|
|
|
// Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
|
|
// must delete it in a finally block; separating path generation from the copy ensures the finally
|
|
// guard fires even when CopyToAsync throws before returning.
|
|
private async Task StageUploadAsync(
|
|
IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
|
|
{
|
|
await using var stagingStream = new FileStream(
|
|
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
|
bufferSize: 81920, useAsync: true);
|
|
await using var uploadStream = audioFile.OpenReadStream();
|
|
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
|
|
}
|
|
|
|
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
|
|
// disk-hygiene concern, not a request failure.
|
|
private void DeleteStagingFile(string stagingPath)
|
|
{
|
|
try
|
|
{
|
|
if (System.IO.File.Exists(stagingPath))
|
|
{
|
|
System.IO.File.Delete(stagingPath);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
|
|
}
|
|
}
|
|
|
|
// --- Literal-segment routes first ---
|
|
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
|
|
// resolution never treats "page", "upload", or "meta" as a trackId.
|
|
|
|
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=&releaseId=
|
|
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
|
|
// q/album/genre/releaseId build an optional TrackFilter; all null → null passthrough (no filtering).
|
|
// releaseId is the authoritative release→tracks join (exact match), preferred over album title.
|
|
[HttpGet("page")]
|
|
public async Task<ActionResult> GetPage(
|
|
[FromQuery] int page = 1,
|
|
[FromQuery] int pageSize = 20,
|
|
[FromQuery] string? sortColumn = null,
|
|
[FromQuery] bool sortDescending = false,
|
|
[FromQuery] string? q = null,
|
|
[FromQuery] string? album = null,
|
|
[FromQuery] string? genre = null,
|
|
[FromQuery] long? releaseId = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre, ReleaseId = releaseId };
|
|
var effectiveFilter = filter.IsEmpty ? null : filter;
|
|
|
|
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
|
|
if (!result.Success || result.Value is null)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("GetPage failed: {Error}", error);
|
|
return StatusCode(500, "Failed to load tracks");
|
|
}
|
|
|
|
return Ok(result.Value);
|
|
}
|
|
|
|
// GET api/track/albums (unauthenticated)
|
|
// All releases with per-release track counts. Public browse data, same posture as GET
|
|
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
|
// Route name kept as "albums" for client/proxy compatibility; the payload is List<ReleaseDto>.
|
|
[HttpGet("albums")]
|
|
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
|
|
{
|
|
var result = await _sqlTrackService.GetReleases(ct);
|
|
if (!result.Success || result.Value is null)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("GetAlbums failed: {Error}", error);
|
|
return StatusCode(500, "Failed to load albums");
|
|
}
|
|
|
|
return Ok(result.Value);
|
|
}
|
|
|
|
// GET api/track/release/exists?title=...&artist=... ([ApiKeyAuthorize])
|
|
// Upload-form pre-flight: does a release with this exact (title, artist) already exist? Returns the
|
|
// matching ReleaseDto (so the caller can name it in the block message) or 404 when none exists. Uses
|
|
// the same GetReleaseByTitleAndArtist read the upload create-path duplicate guard uses, so the
|
|
// pre-flight and the server backstop agree on the match by construction (exact ordinal comparison,
|
|
// soft-deleted rows excluded). "release/exists" is a literal 2-segment route declared before the
|
|
// parameterized "{trackId}" route and distinct from "release/{id:long}" (different segment shape).
|
|
[ApiKeyAuthorize]
|
|
[HttpGet("release/exists")]
|
|
public async Task<ActionResult> ReleaseExists(
|
|
[FromQuery] string? title,
|
|
[FromQuery] string? artist,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
|
|
return BadRequest("title and artist are both required");
|
|
|
|
var result = await _sqlTrackService.GetReleaseByTitleAndArtist(title, artist, ct);
|
|
if (!result.Success)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("ReleaseExists failed for ({Title}, {Artist}): {Error}", title, artist, error);
|
|
return StatusCode(500, "Failed to check release");
|
|
}
|
|
|
|
if (result.Value is null)
|
|
return NotFound();
|
|
|
|
return Ok(result.Value);
|
|
}
|
|
|
|
// GET api/track/genres (unauthenticated)
|
|
// Distinct non-null genres with track counts. Public browse data, same posture as GET
|
|
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
|
[HttpGet("genres")]
|
|
public async Task<ActionResult> GetGenres(CancellationToken ct = default)
|
|
{
|
|
var result = await _sqlTrackService.GetDistinctGenres(ct);
|
|
if (!result.Success || result.Value is null)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("GetGenres failed: {Error}", error);
|
|
return StatusCode(500, "Failed to load genres");
|
|
}
|
|
|
|
return Ok(result.Value);
|
|
}
|
|
|
|
// GET api/track/random (unauthenticated)
|
|
// Picks one track at random from the full library and returns its metadata. Public, same auth
|
|
// posture as GET api/track/page. Selection math lives in the SQL service/repository, not here.
|
|
// 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 +
|
|
// TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there.
|
|
[HttpGet("random")]
|
|
public async Task<ActionResult> GetRandom(CancellationToken cancellationToken = default)
|
|
{
|
|
var result = await _sqlTrackService.GetRandom(cancellationToken);
|
|
if (!result.Success)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("GetRandom failed: {Error}", error);
|
|
return StatusCode(500, "Failed to load track");
|
|
}
|
|
|
|
if (result.Value is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Ok(result.Value);
|
|
}
|
|
|
|
// GET api/track/waveform-status ([ApiKeyAuthorize])
|
|
// Admin backfill view: returns every track with flags for whether each waveform datum is stored —
|
|
// the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
|
|
// datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
|
|
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
|
|
// segment is never treated as a trackId.
|
|
[ApiKeyAuthorize]
|
|
[HttpGet("waveform-status")]
|
|
public async Task<ActionResult> GetWaveformStatus()
|
|
{
|
|
var tracks = await _sqlTrackService.GetAll();
|
|
if (!tracks.Success || tracks.Value is null)
|
|
{
|
|
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("GetWaveformStatus failed to load tracks: {Error}", error);
|
|
return StatusCode(500, "Failed to load tracks");
|
|
}
|
|
|
|
var status = new List<WaveformStatusDto>(tracks.Value.Count);
|
|
foreach (var track in tracks.Value)
|
|
{
|
|
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
|
|
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
|
|
status.Add(new WaveformStatusDto
|
|
{
|
|
TrackId = track.Id,
|
|
EntryKey = track.EntryKey,
|
|
TrackName = track.TrackName,
|
|
HasProfile = profile is not null,
|
|
HasHighRes = highRes is not null,
|
|
});
|
|
}
|
|
|
|
return Ok(status);
|
|
}
|
|
|
|
// POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
|
|
// One-time admin backfill: for every track whose SQL duration is still null, read the duration from
|
|
// the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
|
|
// only touches still-missing rows. Returns { updated, skipped }. Declared in the literal-route block
|
|
// (before "{trackId}") so the segment is never treated as a trackId.
|
|
[ApiKeyAuthorize]
|
|
[HttpPost("duration/backfill")]
|
|
public async Task<ActionResult> BackfillDurations(CancellationToken cancellationToken)
|
|
{
|
|
var result = await _unifiedService.BackfillDurationsAsync(cancellationToken);
|
|
if (!result.Success)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("BackfillDurations failed: {Error}", error);
|
|
return StatusCode(500, error);
|
|
}
|
|
|
|
return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped });
|
|
}
|
|
|
|
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
|
|
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
|
|
// proxies the upload here so it never touches the vault disk path or SQL directly.
|
|
// UnifiedTrackService owns the two-database write.
|
|
//
|
|
// RequestSizeLimit/MultipartBodyLengthLimit set to ~1.86 GB: audio uploads can be tens to
|
|
// hundreds of MB (or over a GB for high-res WAVs); 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. 2_000_000_000 stays below
|
|
// int.MaxValue (2,147,483,647) so it is safe where limits are int-typed.
|
|
[ApiKeyAuthorize]
|
|
[HttpPost("upload")]
|
|
[RequestSizeLimit(2_000_000_000)]
|
|
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
|
|
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
|
|
[FromForm] IFormFile? audioFile,
|
|
[FromForm] string? trackName,
|
|
[FromForm] string? artist,
|
|
[FromForm] string? album,
|
|
[FromForm] string? genre,
|
|
[FromForm] string? description,
|
|
[FromForm] string? releaseDate,
|
|
[FromForm] string? originalFileName,
|
|
[FromForm] long createdByUserId,
|
|
[FromForm] string? releaseType,
|
|
[FromForm] string? medium,
|
|
[FromForm] int? trackNumber,
|
|
[FromForm] long? releaseId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
|
|
trackName, artist, originalFileName, audioFile?.Length);
|
|
|
|
if (audioFile is null || audioFile.Length == 0)
|
|
{
|
|
return BadRequest("Audio file is required");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(trackName))
|
|
{
|
|
return BadRequest("trackName is required");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(artist))
|
|
{
|
|
return BadRequest("artist is required");
|
|
}
|
|
|
|
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
|
|
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
|
|
{
|
|
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac 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;
|
|
}
|
|
|
|
// Default to Single for null/unparseable release type; default track number to a valid 1-based value.
|
|
ReleaseType parsedReleaseType;
|
|
if (!string.IsNullOrWhiteSpace(releaseType)
|
|
&& Enum.TryParse<ReleaseType>(releaseType, ignoreCase: true, out var rt)
|
|
&& Enum.IsDefined(rt))
|
|
{
|
|
parsedReleaseType = rt;
|
|
}
|
|
else
|
|
{
|
|
parsedReleaseType = ReleaseType.Single;
|
|
if (!string.IsNullOrWhiteSpace(releaseType))
|
|
_logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
|
|
}
|
|
// Default to Cut for null/unparseable medium, mirroring the releaseType defensive parse above.
|
|
ReleaseMedium parsedMedium;
|
|
if (!string.IsNullOrWhiteSpace(medium)
|
|
&& Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var rm)
|
|
&& Enum.IsDefined(rm))
|
|
{
|
|
parsedMedium = rm;
|
|
}
|
|
else
|
|
{
|
|
parsedMedium = ReleaseMedium.Cut;
|
|
if (!string.IsNullOrWhiteSpace(medium))
|
|
_logger.LogWarning("UploadTrack: unrecognised medium value '{Value}', defaulting to Cut", medium);
|
|
}
|
|
|
|
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
|
|
|
|
// Build the staging path before the copy so the finally block can delete the partial file
|
|
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
|
|
var stagingPath = BuildStagingPath(uploadExtension);
|
|
try
|
|
{
|
|
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
|
|
|
|
var result = await _unifiedService.UploadAsync(
|
|
stagingPath,
|
|
trackName,
|
|
artist,
|
|
string.IsNullOrWhiteSpace(album) ? null : album,
|
|
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
|
string.IsNullOrWhiteSpace(description) ? null : description,
|
|
parsedReleaseDate,
|
|
createdByUserId,
|
|
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
|
parsedReleaseType,
|
|
parsedMedium,
|
|
resolvedTrackNumber,
|
|
releaseId,
|
|
cancellationToken);
|
|
|
|
if (!result.Success || result.Value is null)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
|
|
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
|
|
|
|
// A cardinality or duplicate-release rejection is a well-formed request that violates a
|
|
// domain rule, so it is 409 Conflict — distinct from the 500 used for processing failure.
|
|
// The marker is stripped so the client sees only the human-readable detail.
|
|
if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
|
|
{
|
|
return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
|
|
}
|
|
|
|
if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal))
|
|
{
|
|
return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]);
|
|
}
|
|
|
|
return StatusCode(500, error);
|
|
}
|
|
|
|
_logger.LogInformation("UploadTrack succeeded: id={Id}, entryKey={EntryKey}", result.Value.Id, result.Value.EntryKey);
|
|
return Ok(result.Value);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "UploadTrack failed for {TrackName}", trackName);
|
|
return StatusCode(500, "Internal server error");
|
|
}
|
|
finally
|
|
{
|
|
DeleteStagingFile(stagingPath);
|
|
}
|
|
}
|
|
|
|
// GET api/track/meta/{id}: single track metadata from SQL.
|
|
[ApiKeyAuthorize]
|
|
[HttpGet("meta/{id:long}")]
|
|
public async Task<ActionResult> GetMeta(long id)
|
|
{
|
|
var result = await _sqlTrackService.GetById(id);
|
|
if (!result.Success)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("GetMeta failed for {TrackId}: {Error}", id, error);
|
|
return StatusCode(500, "Failed to load track");
|
|
}
|
|
|
|
if (result.Value is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Ok(result.Value);
|
|
}
|
|
|
|
// GET api/track/meta/by-key/{entryKey}: single track metadata by vault entry key.
|
|
// Unauthenticated, like GET api/track/page and GET api/track/{id} — reachable through the
|
|
// public proxy. 3-segment route, so no collision with meta/{id:long} or {trackId}.
|
|
[HttpGet("meta/by-key/{entryKey}")]
|
|
public async Task<ActionResult> GetMetaByKey(string entryKey)
|
|
{
|
|
var result = await _sqlTrackService.GetByEntryKey(entryKey);
|
|
if (!result.Success)
|
|
{
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("GetMetaByKey failed for {EntryKey}: {Error}", entryKey, error);
|
|
return StatusCode(500, "Failed to load track");
|
|
}
|
|
|
|
if (result.Value is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Ok(result.Value);
|
|
}
|
|
|
|
// PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body.
|
|
[ApiKeyAuthorize]
|
|
[HttpPut("meta/{id:long}")]
|
|
public async Task<ActionResult> UpdateMeta(long id, [FromBody] UpdateTrackMetadataRequest request)
|
|
{
|
|
var lookup = await _sqlTrackService.GetById(id);
|
|
if (!lookup.Success)
|
|
{
|
|
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("UpdateMeta lookup failed for {TrackId}: {Error}", id, error);
|
|
return StatusCode(500, "Failed to load track");
|
|
}
|
|
|
|
if (lookup.Value is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (request.TrackNumber is <= 0)
|
|
return BadRequest("trackNumber must be a positive integer when provided.");
|
|
|
|
var track = lookup.Value;
|
|
|
|
// Track-cardinal fields update the track row directly.
|
|
track.TrackName = request.TrackName;
|
|
if (request.TrackNumber is > 0)
|
|
track.TrackNumber = request.TrackNumber.Value;
|
|
|
|
// Release-cardinal fields update the linked release (handled in TrackManager.Update, which
|
|
// persists track.Release when the track carries a resolved ReleaseId). The loaded track has
|
|
// its Release populated via the Include; mutate it in place so the edited values flow through.
|
|
// A loose track (no release) cannot take release-cardinal edits — there is no release row to
|
|
// write to — so these fields are simply not persisted in that case.
|
|
if (track.Release is { } release)
|
|
{
|
|
release.Artist = request.Artist;
|
|
release.Title = request.Album ?? string.Empty;
|
|
release.Genre = request.Genre;
|
|
release.Description = request.Description;
|
|
release.ReleaseDate = request.ReleaseDate;
|
|
|
|
// ImagePath is tri-state: null = no change, "" = clear, value = set.
|
|
if (request.ImagePath is not null)
|
|
release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
|
|
|
|
// ReleaseType is non-null on the release; null in the request means "no change".
|
|
if (request.ReleaseType is not null)
|
|
release.ReleaseType = request.ReleaseType.Value;
|
|
|
|
// Medium is non-null on the release; null in the request means "no change".
|
|
if (request.Medium is not null)
|
|
{
|
|
release.Medium = request.Medium.Value;
|
|
|
|
// ReleaseType is meaningful only for Cut. When the medium is anything else, reset
|
|
// ReleaseType to the DB-level default rather than leaving a stale studio-format value —
|
|
// mirroring TrackConverter's read-path nulling of ReleaseType for non-Cut releases. This
|
|
// runs after the ReleaseType apply above, so it correctly overrides a contradictory
|
|
// ReleaseType sent in the same request alongside a non-Cut medium.
|
|
if (request.Medium.Value != ReleaseMedium.Cut)
|
|
release.ReleaseType = ReleaseType.Single;
|
|
}
|
|
}
|
|
|
|
var update = await _sqlTrackService.Update(track);
|
|
if (!update.Success)
|
|
{
|
|
var error = update.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("UpdateMeta failed for {TrackId}: {Error}", id, error);
|
|
return StatusCode(500, "Failed to update track");
|
|
}
|
|
|
|
return Ok();
|
|
}
|
|
|
|
// DELETE api/track/{id}: removes the SQL row then the vault entry. UnifiedTrackService owns
|
|
// the ordering and orphan handling. Declared (with the long route constraint) before the
|
|
// string "{trackId}" GET so a numeric id routes here.
|
|
[ApiKeyAuthorize]
|
|
[HttpDelete("{id:long}")]
|
|
public async Task<ActionResult> DeleteTrack(long id, CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("DeleteTrack called with id: {Id}", id);
|
|
|
|
var result = await _unifiedService.DeleteAsync(id, cancellationToken);
|
|
if (result.Success)
|
|
{
|
|
return Ok();
|
|
}
|
|
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
_logger.LogError("DeleteTrack failed for id {Id}: {Error}", id, error);
|
|
return StatusCode(500, error);
|
|
}
|
|
|
|
// POST api/track/{id}/replace-audio ([ApiKeyAuthorize])
|
|
// Swap an existing track's audio bytes from a raw upload, preserving the track's id, EntryKey,
|
|
// release membership, position, and metadata. UnifiedTrackService.ReplaceAudioAsync owns the
|
|
// vault swap + waveform regen; nothing in SQL is written. Mirrors the upload endpoint's temp-file
|
|
// streaming and ~1.86 GB ceiling (a WAV replace is a large-body upload like the original). The
|
|
// literal "{id:long}/replace-audio" segment is declared in the literal-route block so it never
|
|
// resolves to the parameterized "{trackId}" GET.
|
|
[ApiKeyAuthorize]
|
|
[HttpPost("{id:long}/replace-audio")]
|
|
[RequestSizeLimit(2_000_000_000)]
|
|
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
|
|
public async Task<ActionResult> ReplaceAudio(
|
|
long id,
|
|
[FromForm] IFormFile? audioFile,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("ReplaceAudio called: id={Id}, size={Size}", id, audioFile?.Length);
|
|
|
|
if (audioFile is null || audioFile.Length == 0)
|
|
{
|
|
return BadRequest("Audio file is required");
|
|
}
|
|
|
|
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
|
|
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
|
|
{
|
|
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
|
|
}
|
|
|
|
// Build the staging path before the copy so the finally block can delete the partial file
|
|
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
|
|
var stagingPath = BuildStagingPath(uploadExtension);
|
|
try
|
|
{
|
|
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
|
|
|
|
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
|
|
if (result.Success)
|
|
{
|
|
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
|
|
return Ok();
|
|
}
|
|
|
|
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to replace audio";
|
|
if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
_logger.LogError("ReplaceAudio failed for id {Id}: {Error}", id, error);
|
|
return StatusCode(500, error);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "ReplaceAudio failed for id {Id}", id);
|
|
return StatusCode(500, "Internal server error");
|
|
}
|
|
finally
|
|
{
|
|
DeleteStagingFile(stagingPath);
|
|
}
|
|
}
|
|
|
|
// 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}")]
|
|
public async Task<ActionResult> GetTrack(string trackId)
|
|
{
|
|
_logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);
|
|
|
|
try
|
|
{
|
|
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
|
if (vault == null)
|
|
{
|
|
_logger.LogWarning("Tracks vault not found");
|
|
return NotFound();
|
|
}
|
|
|
|
var mediaStream = await vault.GetEntryStreamAsync(trackId);
|
|
if (mediaStream == null)
|
|
{
|
|
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
|
return NotFound();
|
|
}
|
|
|
|
// Resolve MIME and log before handing the stream to File().
|
|
// If anything here throws, the finally block disposes the wrapper
|
|
// (and its inner FileStream) so neither leaks. On the success path
|
|
// File() takes ownership of the inner stream; ASP.NET Core disposes
|
|
// it after the response body is sent. The wrapper is a thin struct
|
|
// with no extra resources, so disposing it after extracting the
|
|
// inner stream is a no-op — we only call Dispose() in the catch path.
|
|
string streamMimeType;
|
|
long streamLength;
|
|
Stream innerStream;
|
|
try
|
|
{
|
|
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
|
streamLength = mediaStream.Stream.Length;
|
|
innerStream = mediaStream.Stream;
|
|
}
|
|
catch
|
|
{
|
|
await mediaStream.DisposeAsync();
|
|
throw;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
|
trackId, streamLength);
|
|
// enableRangeProcessing: true — seek is served by HTTP Range requests.
|
|
// The FileStream is seekable, so ASP.NET Core honours an incoming
|
|
// Range header by slicing the file and responding 206 Partial Content.
|
|
return File(innerStream, streamMimeType, enableRangeProcessing: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error retrieving track: {TrackId}", trackId);
|
|
return StatusCode(500, "Internal server error");
|
|
}
|
|
}
|
|
|
|
// GET api/track/{trackId}/waveform (unauthenticated)
|
|
// Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
|
|
// data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
|
|
// (existing tracks predate profiling, or computation failed at upload — the frontend falls back
|
|
// to a flat seekbar). The "waveform" literal suffix keeps this distinct from the audio route.
|
|
[HttpGet("{trackId}/waveform")]
|
|
public async Task<ActionResult> GetWaveform(string trackId)
|
|
{
|
|
var bytes = await _waveformProfileService.GetProfileAsync(trackId);
|
|
if (bytes is null)
|
|
{
|
|
_logger.LogInformation("No waveform profile for track: {TrackId}", trackId);
|
|
return NotFound();
|
|
}
|
|
|
|
return Ok(new WaveformProfileDto
|
|
{
|
|
BucketCount = bytes.Length,
|
|
Data = Convert.ToBase64String(bytes),
|
|
});
|
|
}
|
|
|
|
// GET api/track/{trackId}/waveform/high-res (unauthenticated)
|
|
// Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum
|
|
// from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer
|
|
// fetches for whatever track is currently playing/selected — the release is only addressing context.
|
|
// Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the
|
|
// "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is
|
|
// stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the
|
|
// parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
|
|
[HttpGet("{trackId}/waveform/high-res")]
|
|
public async Task<ActionResult> GetHighResWaveform(string trackId)
|
|
{
|
|
var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms);
|
|
if (bytes is null)
|
|
{
|
|
_logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId);
|
|
return NotFound();
|
|
}
|
|
|
|
return Ok(new WaveformProfileDto
|
|
{
|
|
BucketCount = bytes.Length,
|
|
Data = Convert.ToBase64String(bytes),
|
|
});
|
|
}
|
|
|
|
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
|
|
// Admin backfill: compute and store a waveform profile for an existing track from its vault
|
|
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
|
|
// WAV cannot be decoded or the vault write fails. Used by the CMS PreProcessing panel for
|
|
// tracks that predate the WaveformSeeker feature.
|
|
[ApiKeyAuthorize]
|
|
[HttpPost("{trackId}/waveform")]
|
|
public async Task<ActionResult> GenerateWaveform(string trackId)
|
|
{
|
|
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
|
if (audio is null)
|
|
{
|
|
_logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
|
|
return NotFound();
|
|
}
|
|
|
|
var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
|
|
if (!stored)
|
|
{
|
|
_logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
|
|
return StatusCode(500, "Failed to generate waveform profile.");
|
|
}
|
|
|
|
return Ok();
|
|
}
|
|
|
|
// POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
|
|
// Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
|
|
// the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
|
|
// track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
|
|
// Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
|
|
// is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
|
|
// before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
|
|
[ApiKeyAuthorize]
|
|
[HttpPost("{trackId}/waveform/high-res")]
|
|
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
|
|
{
|
|
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
|
if (audio is null)
|
|
{
|
|
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
|
|
return NotFound();
|
|
}
|
|
|
|
var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
|
audio.Buffer, trackId, audio.Duration);
|
|
if (!stored)
|
|
{
|
|
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
|
|
return StatusCode(500, "Failed to generate high-res waveform datum.");
|
|
}
|
|
|
|
return Ok();
|
|
}
|
|
|
|
[ApiKeyAuthorize]
|
|
[HttpPut("{trackId}")]
|
|
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
|
{
|
|
_logger.LogInformation("PutTrack called with trackId: {TrackId}", trackId);
|
|
|
|
// Reject unknown MIME types up front rather than silently storing the binary
|
|
// with a ".bin" extension. GetExtension returns ".bin" for any unrecognised
|
|
// MIME, so treat that as the sentinel for an unsupported type.
|
|
if (MimeTypeExtensions.GetExtension(track.Mime) == ".bin")
|
|
{
|
|
_logger.LogWarning("PutTrack rejected: unsupported MIME type '{Mime}' for track {TrackId}", track.Mime, trackId);
|
|
return BadRequest($"Unsupported MIME type: {track.Mime}");
|
|
}
|
|
|
|
var audioBinary = AudioBinary.From(track);
|
|
// Direct FileDatabase write: this endpoint receives an already-processed AudioBinaryDto,
|
|
// not a WAV file, so TrackContentService.AddTrackFromWavAsync does not apply. See constructor comment.
|
|
var success = await _fileDatabase.RegisterResourceAsync(
|
|
DeepDrftContent.Constants.VaultConstants.Tracks, trackId, audioBinary);
|
|
return success ? Ok() : BadRequest("Failed to store audio track");
|
|
}
|
|
}
|