a19a734757
Add GET api/track/{trackEntryKey}/waveform/high-res (+ proxy), ITrackDataService.GetTrackWaveform; rewire visualizer to resolve the current track's EntryKey and re-fetch on track change. Retire the client mix-waveform read path.
177 lines
8.4 KiB
C#
177 lines
8.4 KiB
C#
using DeepDrftContent;
|
|
using DeepDrftContent.Constants;
|
|
using DeepDrftContent.FileDatabase.Models;
|
|
using DeepDrftContent.Processors;
|
|
using DeepDrftData;
|
|
using DeepDrftModels.Enums;
|
|
using NetBlocks.Models;
|
|
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
|
|
|
namespace DeepDrftAPI.Services;
|
|
|
|
/// <summary>
|
|
/// Host-internal orchestrator for the two release metadata write paths. Mirrors
|
|
/// <see cref="UnifiedTrackService"/>: it makes DeepDrftAPI the single authority over both the vault
|
|
/// (FileDatabase) and SQL satellite rows, so the controller stays a thin HTTP boundary and no caller
|
|
/// coordinates the two stores.
|
|
/// </summary>
|
|
public class UnifiedReleaseService
|
|
{
|
|
/// <summary>Error message returned when the Mix release has no linked track.</summary>
|
|
public const string MixHasNoTrackMessage = "Mix release has no track.";
|
|
|
|
/// <summary>Error message returned when the Mix track has no audio stored in the vault.</summary>
|
|
public const string MixTrackNoAudioMessage = "No audio stored for the Mix track.";
|
|
|
|
private readonly IReleaseService _releaseService;
|
|
private readonly FileDb _fileDatabase;
|
|
private readonly ImageProcessor _imageProcessor;
|
|
private readonly TrackContentService _trackContentService;
|
|
private readonly WaveformProfileService _waveformProfileService;
|
|
private readonly ILogger<UnifiedReleaseService> _logger;
|
|
|
|
public UnifiedReleaseService(
|
|
IReleaseService releaseService,
|
|
FileDb fileDatabase,
|
|
ImageProcessor imageProcessor,
|
|
TrackContentService trackContentService,
|
|
WaveformProfileService waveformProfileService,
|
|
ILogger<UnifiedReleaseService> logger)
|
|
{
|
|
_releaseService = releaseService;
|
|
_fileDatabase = fileDatabase;
|
|
_imageProcessor = imageProcessor;
|
|
_trackContentService = trackContentService;
|
|
_waveformProfileService = waveformProfileService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Process a hero image into the Images vault, then point the release's Session satellite at it.
|
|
/// The medium check lives in <see cref="IReleaseService.SetSessionHeroImageAsync"/>: if the release
|
|
/// is not a Session, the satellite is not written and the image is orphaned (logged, recoverable).
|
|
/// </summary>
|
|
public async Task<Result> SetHeroImageAsync(long releaseId, IFormFile imageFile, CancellationToken ct)
|
|
{
|
|
if (MimeTypeExtensions.GetExtension(imageFile.ContentType) == ".bin")
|
|
{
|
|
_logger.LogWarning(
|
|
"SetHeroImage rejected: unsupported content type '{ContentType}' for release {ReleaseId}",
|
|
imageFile.ContentType, releaseId);
|
|
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
|
|
}
|
|
|
|
byte[] buffer;
|
|
await using (var stream = imageFile.OpenReadStream())
|
|
using (var memory = new MemoryStream())
|
|
{
|
|
await stream.CopyToAsync(memory, ct);
|
|
buffer = memory.ToArray();
|
|
}
|
|
|
|
var imageBinary = _imageProcessor.Process(buffer, imageFile.ContentType);
|
|
if (imageBinary is null)
|
|
{
|
|
_logger.LogWarning("SetHeroImage: ImageProcessor rejected content type '{ContentType}'", imageFile.ContentType);
|
|
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
|
|
}
|
|
|
|
var entryKey = Guid.NewGuid().ToString("N");
|
|
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
|
|
if (!stored)
|
|
{
|
|
_logger.LogError("SetHeroImage: vault write failed for release {ReleaseId}, entryKey={EntryKey}", releaseId, entryKey);
|
|
return Result.CreateFailResult("Failed to store hero image.");
|
|
}
|
|
|
|
var linked = await _releaseService.SetSessionHeroImageAsync(releaseId, entryKey, ct);
|
|
if (!linked.Success)
|
|
{
|
|
// Vault write succeeded, SQL link failed — image is orphaned in the Images vault under
|
|
// entryKey. Log loudly (include entryKey) so it is recoverable manually.
|
|
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError(
|
|
"Hero image stored in vault but Session link failed. Orphaned entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
|
|
entryKey, releaseId, error);
|
|
return linked;
|
|
}
|
|
|
|
return Result.CreatePassResult();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
|
|
/// resolution (≈333 samples/sec derived from the track's duration; see
|
|
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms vault under the track's
|
|
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
|
|
/// track's EntryKey — the Mix is single-track. Under the per-track model (phase-12 §5) this is the
|
|
/// same datum every track now carries. The visualizer fetches it via the track-cardinal
|
|
/// <c>GET api/track/{trackEntryKey}/waveform/high-res</c> (12.B2); the Mix satellite link and the
|
|
/// legacy release-addressed read path are retained transitionally and no longer feed the visualizer.
|
|
/// </summary>
|
|
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
|
|
{
|
|
var lookup = await _releaseService.GetByIdAsync(releaseId, ct);
|
|
if (!lookup.Success)
|
|
{
|
|
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("TriggerMixWaveform: release lookup failed for {ReleaseId}: {Error}", releaseId, error);
|
|
return Result.CreateFailResult("Failed to load release.");
|
|
}
|
|
|
|
if (lookup.Value is null)
|
|
return Result.CreateFailResult(ReleaseManager.ReleaseNotFoundMessage);
|
|
|
|
// Pre-check medium here (before fetching audio) to avoid expensive waveform compute on a
|
|
// non-Mix release. ReleaseManager.SetMixWaveformAsync enforces this too, so the double-check
|
|
// is intentional — the orchestrator's guard is the cheap early-exit.
|
|
if (lookup.Value.Medium != ReleaseMedium.Mix)
|
|
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
|
|
|
|
var keysResult = await _releaseService.GetTrackEntryKeysAsync(releaseId, ct);
|
|
if (!keysResult.Success || keysResult.Value is null)
|
|
{
|
|
var error = keysResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError("TriggerMixWaveform: entry-key lookup failed for release {ReleaseId}: {Error}", releaseId, error);
|
|
return Result.CreateFailResult("Failed to load release tracks.");
|
|
}
|
|
|
|
var entryKey = keysResult.Value.FirstOrDefault();
|
|
if (entryKey is null)
|
|
{
|
|
_logger.LogWarning("TriggerMixWaveform: no track on Mix release {ReleaseId}", releaseId);
|
|
return Result.CreateFailResult(MixHasNoTrackMessage);
|
|
}
|
|
|
|
var audio = await _trackContentService.GetAudioBinaryAsync(entryKey);
|
|
if (audio is null)
|
|
{
|
|
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
|
|
return Result.CreateFailResult(MixTrackNoAudioMessage);
|
|
}
|
|
|
|
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
|
|
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
|
|
// high-res datum every track now carries (phase-12 §5).
|
|
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
|
audio.Buffer, entryKey, audio.Duration);
|
|
if (!computed)
|
|
{
|
|
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
|
|
return Result.CreateFailResult("Failed to compute the Mix waveform.");
|
|
}
|
|
|
|
var linked = await _releaseService.SetMixWaveformAsync(releaseId, entryKey, ct);
|
|
if (!linked.Success)
|
|
{
|
|
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
|
_logger.LogError(
|
|
"Mix waveform stored in vault but Mix link failed. Entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
|
|
entryKey, releaseId, error);
|
|
return linked;
|
|
}
|
|
|
|
return Result.CreatePassResult();
|
|
}
|
|
}
|