Files
deepdrft/DeepDrftContent/TrackContentService.cs
T
daniel-c-harvey 16784b37f2 feat(cms): replace track audio in edit form, gate last-track delete
Swap a track's audio by EntryKey (metadata/release/position preserved, waveform regenerated); hide per-track remove on a release's sole persisted track so it can only be replaced or release-deleted.
2026-06-18 12:59:56 -04:00

180 lines
7.4 KiB
C#

using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftModels.Entities;
namespace DeepDrftContent;
/// <summary>
/// Service for managing tracks in both SQL and FileDatabase
/// </summary>
public class TrackContentService
{
private readonly FileDatabase.Services.FileDatabase _fileDatabase;
private readonly AudioProcessorRouter _audioProcessorRouter;
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessorRouter audioProcessorRouter)
{
_fileDatabase = fileDatabase;
_audioProcessorRouter = audioProcessorRouter;
}
/// <summary>
/// Adds a new track from a supported audio file (.wav, .mp3, .flac) to both databases. The
/// router selects the processor by extension; original bytes are stored for mp3/flac (no
/// transcoding), while EXTENSIBLE WAVs are normalized to standard PCM at storage time.
/// </summary>
/// <param name="audioFilePath">Path to the audio file</param>
/// <param name="trackName">Name of the track</param>
/// <param name="artist">Artist name</param>
/// <param name="album">Optional album name</param>
/// <param name="genre">Optional genre</param>
/// <param name="releaseDate">Optional release date</param>
/// <param name="originalFileName">Optional original browser filename captured at upload time</param>
/// <returns>The track entity with generated ID and media path</returns>
public async Task<TrackEntity?> AddTrackAsync(
string audioFilePath,
string trackName,
string artist,
string? album = null,
string? genre = null,
DateOnly? releaseDate = null,
string? originalFileName = null)
{
try
{
// Process the audio file (routed by extension)
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
if (audioBinary == null)
{
throw new InvalidOperationException("Failed to process audio file");
}
// Generate a unique track ID
var trackId = Guid.NewGuid().ToString();
// Ensure tracks vault exists
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
{
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
// Store the audio in FileDatabase
var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, trackId, audioBinary);
if (!success)
{
throw new InvalidOperationException("Failed to store audio in FileDatabase");
}
// Create the track entity for SQL database. Post Phase 8 §8.0 the entity holds only
// track-cardinal fields; release-cardinal data (artist/album/genre/releaseDate) is
// resolved into a ReleaseEntity by the caller (UnifiedTrackService) and linked via FK.
var trackEntity = new TrackEntity
{
EntryKey = trackId, // FileDatabase entry ID
TrackName = trackName,
OriginalFileName = originalFileName
};
return trackEntity;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.WriteLine($"TrackContentService.AddTrackAsync failed: {ex.Message}");
return null;
}
}
/// <summary>
/// Backward-compatible shim — delegates to <see cref="AddTrackAsync"/>. The router accepts WAV
/// alongside MP3 and FLAC, so this carries no WAV-specific logic of its own.
/// </summary>
public Task<TrackEntity?> AddTrackFromWavAsync(
string wavFilePath,
string trackName,
string artist,
string? album = null,
string? genre = null,
DateOnly? releaseDate = null,
string? originalFileName = null) =>
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName);
/// <summary>
/// Swaps the audio bytes for an existing track in place: processes a new audio file and
/// re-registers it under the SAME <paramref name="entryKey"/> in the tracks vault. The track's
/// vault key — and therefore its SQL link, release membership, position, and metadata — is
/// untouched; only the binary changes. The prior entry is removed first so a replacement whose
/// extension differs from the original (e.g. .wav → .flac) does not strand the old file on disk
/// under its former filename. Returns the freshly stored <see cref="AudioBinary"/> on success
/// (so the caller can regenerate waveform data from the same bytes), or null on processing or
/// vault failure — matching the FileDatabase swallow-and-return-null contract.
/// </summary>
public async Task<AudioBinary?> ReplaceTrackAudioAsync(string entryKey, string audioFilePath)
{
try
{
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
if (audioBinary == null)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: processing returned null for {entryKey}");
return null;
}
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
{
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
// Drop the old entry first. The backing file is keyed by entryKey + its *stored*
// extension, so a register alone would leave a stale file when the new format differs.
// A null/false removal is non-fatal (the entry may already be absent); the register
// below is the authoritative write.
await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audioBinary);
if (!success)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: vault write failed for {entryKey}");
return null;
}
return audioBinary;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync failed: {ex.Message}");
return null;
}
}
/// <summary>
/// Retrieves audio binary from FileDatabase
/// </summary>
/// <param name="trackId">Track ID (EntryKey)</param>
/// <returns>Audio binary or null if not found</returns>
public async Task<AudioBinary?> GetAudioBinaryAsync(string trackId)
{
return await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, trackId);
}
/// <summary>
/// Checks if FileDatabase is available and tracks vault exists
/// </summary>
public bool IsFileDatabaseReady()
{
return _fileDatabase.HasVault(VaultConstants.Tracks);
}
/// <summary>
/// Initializes the tracks vault if it doesn't exist
/// </summary>
public async Task InitializeTracksVaultAsync()
{
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
{
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
}
}