e8359d5473
UpdateDuration's null guard matched zero rows for tracks that already had a duration (all normally-uploaded tracks). Add SetDurationAsync/SetDuration/ITrackService.SetDuration with no null guard; fail on zero rows. ReplaceAudioAsync now calls SetDuration.
386 lines
19 KiB
C#
386 lines
19 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class UnifiedTrackService
|
|
{
|
|
internal const string TrackNotFoundMessage = "Track not found.";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<UnifiedTrackService> _logger;
|
|
|
|
public UnifiedTrackService(
|
|
TrackContentService contentTrackContentService,
|
|
ITrackService sqlTrackService,
|
|
FileDb fileDatabase,
|
|
WaveformProfileService waveformProfileService,
|
|
ILogger<UnifiedTrackService> logger)
|
|
{
|
|
_contentTrackContentService = contentTrackContentService;
|
|
_sqlTrackService = sqlTrackService;
|
|
_fileDatabase = fileDatabase;
|
|
_waveformProfileService = waveformProfileService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<ResultContainer<TrackDto>> 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,
|
|
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<TrackDto>.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<TrackDto>.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<TrackDto>.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? releaseId = null;
|
|
if (!string.IsNullOrWhiteSpace(album))
|
|
{
|
|
var releaseData = new ReleaseDto
|
|
{
|
|
Title = album,
|
|
Artist = artist,
|
|
Genre = genre,
|
|
Description = description,
|
|
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<TrackDto>.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<TrackDto>.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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<Result> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<ResultContainer<(int Updated, int Skipped)>> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<Result> 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);
|
|
}
|
|
}
|
|
}
|