using DeepDrftContent; using DeepDrftContent.Constants; using DeepDrftContent.Processors; using DeepDrftData; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; using NetBlocks.Models; using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; namespace DeepDrftAPI.Services; /// /// Host-internal orchestrator that makes DeepDrftAPI the single authority over both the /// vault (FileDatabase) and SQL metadata (DeepDrftData). Owns the two-database write/delete /// flow so the controller stays a thin HTTP boundary and no caller coordinates the two stores. /// public class UnifiedTrackService { internal const string TrackNotFoundMessage = "Track not found."; /// /// Stable marker prefixed onto a cardinality-rejection message so the controller can map this /// specific failure to 409 Conflict (a well-formed request that violates a domain rule), /// distinct from the 400 (malformed) and 500 (processing) paths. The human-readable detail /// follows the marker and is what the CMS surfaces to the admin. /// internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: "; /// /// Stable marker prefixed onto a duplicate-release rejection so the controller can map it to 409 /// Conflict, the same way is mapped. Fires when an upload /// with no explicit releaseId would create a release whose (title, artist) already exists in the /// catalogue — the upload form is a create-new tool, never an edit/append path. The human-readable /// detail follows the marker and is what the CMS surfaces to the admin. /// internal const string DuplicateReleaseMarker = "DUPLICATE_RELEASE: "; private readonly TrackContentService _contentTrackContentService; private readonly ITrackService _sqlTrackService; private readonly FileDb _fileDatabase; private readonly WaveformProfileService _waveformProfileService; private readonly ILogger _logger; public UnifiedTrackService( TrackContentService contentTrackContentService, ITrackService sqlTrackService, FileDb fileDatabase, WaveformProfileService waveformProfileService, ILogger logger) { _contentTrackContentService = contentTrackContentService; _sqlTrackService = sqlTrackService; _fileDatabase = fileDatabase; _waveformProfileService = waveformProfileService; _logger = logger; } /// /// Process a supported audio file (.wav, .mp3, .flac) into the vault, then persist its metadata /// to SQL. On success the returned DTO carries the SQL-assigned Id. If the vault write succeeds /// but the SQL persist fails, the audio is orphaned under EntryKey — logged loudly so it is /// recoverable manually. /// public async Task> UploadAsync( string tempFilePath, string trackName, string artist, string? album, string? genre, string? description, DateOnly? releaseDate, long createdByUserId, string? originalFileName, ReleaseType releaseType, ReleaseMedium medium, int trackNumber, long? releaseId, CancellationToken ct) { // Resolve which release this track lands on BEFORE the vault write, so a rejected upload never // orphans audio. Two paths: // - releaseId is null → CREATE path: this is the first row of a submit. (title, artist) must // NOT already exist — the upload form creates new releases only. A pre-existing match is a // duplicate and is blocked (409). // - releaseId is set → ATTACH path: rows 2..N of a within-batch multi-track Cut, attaching // to the release row 1 just created. No (title, artist) lookup — the release id is // authoritative — so the within-batch build is never mistaken for a pre-existing duplicate. // Both paths run the cardinality guard `(liveCount + 1) > Max` (not Session/Mix-hardcoded, so a // future bounded medium is covered by the same line). ResolvedRelease? resolved = null; if (!string.IsNullOrWhiteSpace(album)) { if (releaseId is { } attachId) { var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct); if (!attachPeek.Success) { var error = attachPeek.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error); return ResultContainer.CreateFailResult($"Could not verify the release: {error}"); } // The attach target must be the same release the natural key resolves to — a guard against // a stale/forged releaseId pointing at a different (title, artist) than this row carries. if (attachPeek.Value is not { } target || target.Id != attachId) { return ResultContainer.CreateFailResult( $"{DuplicateReleaseMarker}The release this track should attach to could not be found. " + "Start the upload again."); } var cardinalityCheck = CheckCardinality(target); if (cardinalityCheck is { } violation) return ResultContainer.CreateFailResult(violation); resolved = new ResolvedRelease(target.Id); } else { var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct); if (!peek.Success) { var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error); return ResultContainer.CreateFailResult($"Could not verify the release: {error}"); } // CREATE path: a pre-existing (title, artist) is a duplicate. Block it — the form never // edits or appends to an existing release. if (peek.Value is { } existing) { return ResultContainer.CreateFailResult( $"{DuplicateReleaseMarker}A release titled '{existing.Title}' by {existing.Artist} already " + "exists. The upload form creates new releases only — use the edit tools to change an existing one."); } // resolved stays null → FindOrCreateRelease below creates the release. } } var unpersisted = await _contentTrackContentService.AddTrackAsync( tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName); if (unpersisted is null) { _logger.LogWarning("UploadAsync: content TrackContentService returned null for {TrackName}", trackName); return ResultContainer.CreateFailResult("Failed to process and store WAV."); } unpersisted.TrackNumber = trackNumber; // Resolve the release FK before persisting the track. An upload with an album lands on the // shared release (created on first sighting); an upload without one stays a loose track with // a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader) // rides on the release, not the track. long? resolvedReleaseId = resolved?.Id; if (!string.IsNullOrWhiteSpace(album) && resolvedReleaseId is null) { // CREATE path only: the duplicate guard above proved no (title, artist) match exists, so this // mints the release. (The attach path already resolved the id from the pre-check above and // skips FindOrCreateRelease entirely, so a within-batch row never re-runs the natural-key find.) var releaseData = new ReleaseDto { Title = album, Artist = artist, Genre = genre, Description = description, ReleaseDate = releaseDate, ReleaseType = releaseType, Medium = medium, CreatedByUserId = createdByUserId, }; // FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the // row the concurrent winner just inserted (WasCreated = false). In the CREATE path the // duplicate peek above already verified no pre-existing row exists — so WasCreated = false // means we lost a concurrent-insert race. Treat that as the duplicate condition: reject // rather than silently attaching, keeping the DB unique index as the final safety net. var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct); if (!releaseResult.Success) { var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError( "Track persisted to vault but release resolution failed. Orphaned entry: {EntryKey}. Error: {Error}", unpersisted.EntryKey, error); return ResultContainer.CreateFailResult($"Track was uploaded but could not be saved: {error}"); } var (resolvedRelease, wasCreated) = releaseResult.Value; if (!wasCreated) { // The winning concurrent upload created this release between our peek and our insert. // Reject with the same marker the pre-flight peek uses so the controller maps it to 409. return ResultContainer.CreateFailResult( $"{DuplicateReleaseMarker}A release titled '{resolvedRelease.Title}' by {resolvedRelease.Artist} already " + "exists. The upload form creates new releases only — use the edit tools to change an existing one."); } resolvedReleaseId = resolvedRelease.Id; } var trackDto = TrackConverter.Convert(unpersisted); trackDto.ReleaseId = resolvedReleaseId; trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph. var saveResult = await _sqlTrackService.Create(trackDto); if (!saveResult.Success || saveResult.Value is null) { // Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault // under EntryKey. Log loudly (include EntryKey) so it is recoverable manually. var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError( "Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}", unpersisted.EntryKey, error); return ResultContainer.CreateFailResult($"Track was uploaded but could not be saved: {error}"); } // Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of // the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the // frontend, so a failure here is logged and swallowed — never fails the upload. await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct); return saveResult; } // The release a track resolved onto before the vault write. A null Id is the create path (mint // below); a non-null Id is the attach path (a within-batch multi-track Cut row 2..N). private readonly record struct ResolvedRelease(long Id); // The cardinality guard shared by the attach path and (historically) the create path: a release // already at its medium's Max rejects a further track. Returns the marker-prefixed rejection // message, or null when the add is within limits. The create path never trips this (a brand-new // release has zero tracks and admits its first), so only the attach path calls it today. private static string? CheckCardinality(ReleaseDto release) { var cardinality = MediumRules.CardinalityOf(release.Medium); if (release.TrackCount + 1 > cardinality.Max) { return $"{CardinalityViolationMarker}A {release.Medium} release holds a single track; " + $"'{release.Title}' already has one — edit the existing track or choose a different release."; } return null; } /// /// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes /// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the /// new duration to SQL. Track id, EntryKey, release membership, track number, and all other /// metadata are preserved. The waveform regen is best-effort (a missing datum renders as a flat /// seekbar / blank visualizer downstream), so a datum failure is logged and swallowed rather than /// failing the replace. The duration write is not best-effort — a failure is surfaced so derived /// aggregates (e.g. MixRuntimeSeconds) do not silently go stale. No release-cardinality cascade /// applies: the track count is unchanged, so the single-track-Mix case stays intact. /// public async Task ReplaceAudioAsync(long trackId, string tempFilePath, CancellationToken ct) { var lookup = await _sqlTrackService.GetById(trackId); if (!lookup.Success) { var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error"; _logger.LogError("ReplaceAudioAsync: GetById failed for track {TrackId}: {Error}", trackId, error); return Result.CreateFailResult("Failed to load track."); } if (lookup.Value is null) { return Result.CreateFailResult(TrackNotFoundMessage); } var entryKey = lookup.Value.EntryKey; var newAudio = await _contentTrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath); if (newAudio is null) { _logger.LogWarning("ReplaceAudioAsync: content swap returned null for track {TrackId} ({EntryKey})", trackId, entryKey); return Result.CreateFailResult("Failed to process and store the replacement audio."); } // The old waveform no longer matches the new bytes. Regenerate both datums in place; keyed // by the same EntryKey, the re-run overwrites the stale data (proven re-runnable). The // freshly stored buffer is the authoritative source — no re-read of the vault needed. try { await _waveformProfileService.ComputeAndStoreAsync(newAudio.Buffer, entryKey); await _waveformProfileService.ComputeAndStoreHighResAsync(newAudio.Buffer, entryKey, newAudio.Duration); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "ReplaceAudioAsync: waveform regen failed for {EntryKey}; replace unaffected.", entryKey); } // Write the new duration to SQL. The vault bytes are already swapped, so this is the // authoritative metadata update for the replace. A failure here is surfaced (unlike the // best-effort waveform regen above) because a stale DurationSeconds silently corrupts // derived aggregates (e.g. MixRuntimeSeconds on the home stats endpoint). var durationWrite = await _sqlTrackService.SetDuration(trackId, newAudio.Duration, ct); if (!durationWrite.Success) { var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError( "ReplaceAudioAsync: vault swap succeeded but SQL duration update failed for track {TrackId} ({EntryKey}): {Error}", trackId, entryKey, error); return Result.CreateFailResult("Audio replaced but duration metadata could not be updated."); } return Result.CreatePassResult(); } // Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile // the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer // consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same // audio: read it back from the vault once (the authoritative parsed duration + the stored buffer) // rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload. private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct) { try { var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey); if (audio is null) { _logger.LogWarning( "Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.", entryKey); return; } await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey); await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey); } } /// /// One-time backfill: for every non-deleted track whose SQL duration is still null, read the /// processor-extracted runtime from the vault audio (by EntryKey) and write it to SQL. The migration /// cannot read the vault, so this runs at runtime after deploy. Idempotent — a re-run only touches /// rows still missing a duration. Returns (updated, skipped) counts. A per-track vault miss or SQL /// failure is logged and skipped, never aborting the batch. /// public async Task> BackfillDurationsAsync(CancellationToken ct) { var missing = await _sqlTrackService.GetTracksMissingDuration(ct); if (!missing.Success || missing.Value is null) { var error = missing.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogError("BackfillDurationsAsync: failed to load tracks missing duration: {Error}", error); return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}"); } var updated = 0; var skipped = 0; foreach (var track in missing.Value) { ct.ThrowIfCancellationRequested(); var audio = await _contentTrackContentService.GetAudioBinaryAsync(track.EntryKey); if (audio is null) { _logger.LogWarning("BackfillDurationsAsync: no vault audio for {EntryKey} (track {Id}); skipping.", track.EntryKey, track.Id); skipped++; continue; } var write = await _sqlTrackService.UpdateDuration(track.Id, audio.Duration, ct); if (!write.Success) { var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error"; _logger.LogWarning("BackfillDurationsAsync: SQL update failed for track {Id}: {Error}", track.Id, error); skipped++; continue; } updated++; } _logger.LogInformation("BackfillDurationsAsync complete: {Updated} updated, {Skipped} skipped.", updated, skipped); return ResultContainer<(int, int)>.CreatePassResult((updated, skipped)); } /// /// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete /// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete /// failure is logged as an orphan and swallowed — it is a maintenance concern, not user-facing. /// public async Task DeleteAsync(long id, CancellationToken ct) { var lookup = await _sqlTrackService.GetById(id); if (!lookup.Success) { var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error"; _logger.LogError("DeleteAsync: GetById failed for track {TrackId}: {Error}", id, error); return Result.CreateFailResult("Failed to load track."); } if (lookup.Value is null) { return Result.CreateFailResult(TrackNotFoundMessage); } var entryKey = lookup.Value.EntryKey; var releaseId = lookup.Value.ReleaseId; var sqlDelete = await _sqlTrackService.Delete(id); if (!sqlDelete.Success) { var error = sqlDelete.Messages.FirstOrDefault()?.Message ?? "unknown error"; _logger.LogError("DeleteAsync: SQL delete failed for track {TrackId}: {Error}", id, error); return Result.CreateFailResult("Failed to delete track."); } // Cascade: if this was the last live track on its release, soft-delete the release too so it // does not linger as a 0-track orphan in the albums browser. Non-fatal — the track delete // already succeeded, so any failure here is logged and swallowed, not surfaced to the caller. if (releaseId is { } rid) { await TrySoftDeleteEmptyReleaseAsync(rid, ct); } // Tri-state per FileDatabase's error-swallow contract: null = vault missing/error, // false = entry not present, true = removed. Anything but a clean removal is an orphan. var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey); if (removed is not true) { _logger.LogWarning( "Vault delete did not remove entry after SQL delete. {TrackId} {EntryKey} outcome={Outcome}", id, entryKey, removed); } return Result.CreatePassResult(); } // Soft-delete the release only when no live tracks remain on it. Best-effort: a count or delete // failure here never fails the track delete that triggered it — it is logged so an orphaned // release can be cleaned up later (the migration backfill also catches pre-existing orphans). private async Task TrySoftDeleteEmptyReleaseAsync(long releaseId, CancellationToken ct) { var countResult = await _sqlTrackService.CountLiveTracksByRelease(releaseId, ct); if (!countResult.Success) { var error = countResult.Messages.FirstOrDefault()?.Message ?? "unknown error"; _logger.LogWarning("DeleteAsync: live-track count failed for release {ReleaseId}: {Error}", releaseId, error); return; } if (countResult.Value > 0) { return; } var releaseDelete = await _sqlTrackService.DeleteRelease(releaseId, ct); if (!releaseDelete.Success) { var error = releaseDelete.Messages.FirstOrDefault()?.Message ?? "unknown error"; _logger.LogWarning("DeleteAsync: release soft-delete failed for {ReleaseId}: {Error}", releaseId, error); } } }