Files
deepdrft/DeepDrftAPI/Services/UnifiedReleaseService.cs
T
daniel-c-harvey ca44fc8794 Phase 9 Wave 2: api/release endpoint family — medium-aware reads + metadata writes
Adds ReleaseRepository/ReleaseManager (IReleaseService) for paged medium-filtered
release reads and Session/Mix satellite writes, UnifiedReleaseService orchestrating
vault+SQL, and ReleaseController (5 endpoints). Refactors WaveformProfileService for
configurable bucketCount/vaultName (backward-compatible) and adds the mix-waveforms vault.
Promotes brittle error-string literals to named constants (MixHasNoTrackMessage,
MixTrackNoAudioMessage) on UnifiedReleaseService.
2026-06-12 22:13:31 -04:00

173 lines
7.9 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
{
// High-res bucket count for Mix waveforms — 4x the player-bar default (512), feeding the
// public-site MixWaveformVisualizer.
private const int MixWaveformBucketCount = 2048;
/// <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 (2048-bucket) waveform datum,
/// store it in the MixWaveforms 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.
/// </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);
}
var computed = await _waveformProfileService.ComputeAndStoreAsync(
audio.Buffer, entryKey, MixWaveformBucketCount, VaultConstants.MixWaveforms);
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();
}
}