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; /// /// Host-internal orchestrator for the two release metadata write paths. Mirrors /// : 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. /// public class UnifiedReleaseService { /// Error message returned when the Mix release has no linked track. public const string MixHasNoTrackMessage = "Mix release has no track."; /// Error message returned when the Mix track has no audio stored in the vault. 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 _logger; public UnifiedReleaseService( IReleaseService releaseService, FileDb fileDatabase, ImageProcessor imageProcessor, TrackContentService trackContentService, WaveformProfileService waveformProfileService, ILogger logger) { _releaseService = releaseService; _fileDatabase = fileDatabase; _imageProcessor = imageProcessor; _trackContentService = trackContentService; _waveformProfileService = waveformProfileService; _logger = logger; } /// /// Process a hero image into the Images vault, then point the release's Session satellite at it. /// The medium check lives in : if the release /// is not a Session, the satellite is not written and the image is orphaned (logged, recoverable). /// public async Task 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(); } /// /// 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 /// ), 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 /// GET api/track/{trackEntryKey}/waveform/high-res (12.B2); the Mix satellite link and the /// legacy release-addressed read path are retained transitionally and no longer feed the visualizer. /// public async Task 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(); } }