using DeepDrftContent; using DeepDrftContent.Constants; using DeepDrftData; using DeepDrftModels.Entities; 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."; private readonly TrackContentService _contentTrackContentService; private readonly ITrackService _sqlTrackService; private readonly FileDb _fileDatabase; private readonly ILogger _logger; public UnifiedTrackService( TrackContentService contentTrackContentService, ITrackService sqlTrackService, FileDb fileDatabase, ILogger logger) { _contentTrackContentService = contentTrackContentService; _sqlTrackService = sqlTrackService; _fileDatabase = fileDatabase; _logger = logger; } /// /// Process a WAV into the vault, then persist its metadata to SQL. On success the returned /// entity 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, CancellationToken ct) { var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync( tempFilePath, trackName, artist, album, genre, releaseDate); if (unpersisted is null) { _logger.LogWarning("UploadAsync: content TrackContentService returned null for {TrackName}", trackName); return ResultContainer.CreateFailResult("Failed to process and store WAV."); } unpersisted.CreatedByUserId = createdByUserId; var saveResult = await _sqlTrackService.Create(unpersisted); 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}"); } return saveResult; } /// /// 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 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."); } // 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(); } }