b893ca84de
Promote the Session/Mix single-track rule from a CMS-form convention to a domain invariant: declare cardinality as data in MediumRules, enforce it in UnifiedTrackService before the vault write (no orphan), return 409, and read the same rule in the batch-form collapse.
623 lines
26 KiB
C#
623 lines
26 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 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,
|
|
ILogger<TrackController> logger)
|
|
{
|
|
_trackContentService = trackContentService;
|
|
_fileDatabase = fileDatabase;
|
|
_unifiedService = unifiedService;
|
|
_sqlTrackService = sqlTrackService;
|
|
_waveformProfileService = waveformProfileService;
|
|
_logger = logger;
|
|
}
|
|
|
|
// --- 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/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 a flag for whether a waveform profile is
|
|
// stored in the WaveformProfiles vault. 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);
|
|
status.Add(new WaveformStatusDto
|
|
{
|
|
TrackId = track.Id,
|
|
EntryKey = track.EntryKey,
|
|
TrackName = track.TrackName,
|
|
HasProfile = profile is not null,
|
|
});
|
|
}
|
|
|
|
return Ok(status);
|
|
}
|
|
|
|
// 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 GB: audio 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.DTOs.TrackDto>> UploadTrack(
|
|
[FromForm] IFormFile? audioFile,
|
|
[FromForm] string? trackName,
|
|
[FromForm] string? artist,
|
|
[FromForm] string? album,
|
|
[FromForm] string? genre,
|
|
[FromForm] string? releaseDate,
|
|
[FromForm] string? originalFileName,
|
|
[FromForm] long createdByUserId,
|
|
[FromForm] string? releaseType,
|
|
[FromForm] string? medium,
|
|
[FromForm] int? trackNumber,
|
|
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;
|
|
|
|
// The processor router selects by extension and reads from disk, so the temp file must carry
|
|
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
|
|
// generate our own path preserving the validated .wav/.mp3/.flac extension.
|
|
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
|
|
|
|
try
|
|
{
|
|
await using (var tempStream = new FileStream(
|
|
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
|
bufferSize: 81920, useAsync: true))
|
|
await using (var uploadStream = audioFile.OpenReadStream())
|
|
{
|
|
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
|
}
|
|
|
|
var result = await _unifiedService.UploadAsync(
|
|
tempPath,
|
|
trackName,
|
|
artist,
|
|
string.IsNullOrWhiteSpace(album) ? null : album,
|
|
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
|
parsedReleaseDate,
|
|
createdByUserId,
|
|
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
|
parsedReleaseType,
|
|
parsedMedium,
|
|
resolvedTrackNumber,
|
|
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 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..]);
|
|
}
|
|
|
|
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
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.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);
|
|
}
|
|
|
|
// 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),
|
|
});
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
[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");
|
|
}
|
|
}
|