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: "; 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, DateOnly? releaseDate, long createdByUserId, string? originalFileName, ReleaseType releaseType, ReleaseMedium medium, int trackNumber, CancellationToken ct) { // Cardinality pre-check — BEFORE the vault write so a rejected over-limit add never orphans // audio in the tracks vault. This is a READ-only peek (no release is created for an upload we // may reject); the real FindOrCreateRelease still runs below for the accepted path. Only the // find path can violate: a release that does not yet exist has zero tracks and admits its // first. The guard is the general form `(liveCount + 1) > Max`, not Session/Mix-hardcoded, so // a future bounded medium is covered by the same line. if (!string.IsNullOrWhiteSpace(album)) { 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}"); } if (peek.Value is { } existing) { var cardinality = MediumRules.CardinalityOf(existing.Medium); if (existing.TrackCount + 1 > cardinality.Max) { return ResultContainer.CreateFailResult( $"{CardinalityViolationMarker}A {existing.Medium} release holds a single track; " + $"'{existing.Title}' already has one — edit the existing track or choose a different 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/releaseDate/type/uploader) rides // on the release, not the track. long? releaseId = null; if (!string.IsNullOrWhiteSpace(album)) { var releaseData = new ReleaseDto { Title = album, Artist = artist, Genre = genre, ReleaseDate = releaseDate, ReleaseType = releaseType, Medium = medium, CreatedByUserId = createdByUserId, }; // Medium (like every other field in releaseData) applies only when this upload CREATES the // release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first // upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a // subsequent track add: medium is a release-level property, changed only via the edit path // (PUT api/track/meta), never silently flipped by adding a track to an existing release. var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct); if (!releaseResult.Success || releaseResult.Value is null) { 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}"); } releaseId = releaseResult.Value.Id; } var trackDto = TrackConverter.Convert(unpersisted); trackDto.ReleaseId = releaseId; 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 profile: both stores succeeded, so the upload is a success // regardless of the profile outcome. A missing profile renders as a flat seekbar on the // frontend, so a failure here is logged and swallowed — never fails the upload. await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct); return saveResult; } private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct) { try { var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct); await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey); } } /// /// 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); } } }