4351ae04be
Source read via streamed vault open + bounded staging copy (index-only duration/extension); encoded output walked from a bounded stream (new OggOpusParser.WalkAsync, byte-identical to the buffer oracle) and stored via streaming vault write. Adds parity tests.
282 lines
13 KiB
C#
282 lines
13 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,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
// Process the audio file (routed by extension). The returned plan carries metadata plus a
|
|
// streamed writer — no whole-file buffer (the store-path OOM fix).
|
|
var processed = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath, cancellationToken);
|
|
if (processed == 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);
|
|
}
|
|
|
|
// Stream the audio into the vault. The metadata is supplied directly (there is no in-memory
|
|
// AudioBinary on this path), and the bytes are written progressively from the staging file.
|
|
var metaData = MetaDataFactory.CreateAudioMetaData(trackId, processed.Extension, processed.Duration, processed.Bitrate);
|
|
var success = await _fileDatabase.RegisterResourceStreamingAsync(
|
|
VaultConstants.Tracks, trackId, metaData, processed.WriteToAsync, cancellationToken);
|
|
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,
|
|
// Persist the processor-extracted runtime to SQL so aggregate queries (total mix runtime)
|
|
// need not touch the vault. Same value the high-res waveform compute reads downstream.
|
|
DurationSeconds = processed.Duration
|
|
};
|
|
|
|
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,
|
|
CancellationToken cancellationToken = default) =>
|
|
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName, cancellationToken);
|
|
|
|
/// <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 new audio is streamed to the vault first; only on
|
|
/// confirmed success is a stale old backing file cleaned up. A cross-format replacement (e.g.
|
|
/// .wav → .flac) leaves the old file on disk under its former filename once the index is updated;
|
|
/// the post-success cleanup removes it. For a same-extension overwrite the register alone suffices.
|
|
/// If the register fails the original audio is left intact and null is returned, so the track
|
|
/// remains playable. Returns the freshly stored audio's <b>duration</b> on success (the caller
|
|
/// re-reads the vault for waveform regen and uses this for the SQL duration write) — matching the
|
|
/// FileDatabase swallow-and-return-null contract. The new bytes are never materialized in memory.
|
|
/// </summary>
|
|
public async Task<double?> ReplaceTrackAudioAsync(string entryKey, string audioFilePath, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
// Capture the old extension from the index metadata (not by loading the file — that would
|
|
// pull the whole old audio into memory). After register the index points to the new
|
|
// extension, so we need the old value now to detect a cross-format swap and clean up the
|
|
// stale file post-success.
|
|
var trackVault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
|
var existingMeta = trackVault is null ? null : await trackVault.GetEntryMetadata(entryKey);
|
|
var oldExtension = existingMeta?.Extension;
|
|
|
|
var processed = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath, cancellationToken);
|
|
if (processed == null)
|
|
{
|
|
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: processing returned null for {entryKey}");
|
|
return null;
|
|
}
|
|
|
|
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
|
|
{
|
|
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
|
}
|
|
|
|
// Stream the new audio in. This upserts the index entry (new extension recorded) and writes
|
|
// the new file to disk. If this fails the original entry and file are untouched.
|
|
var metaData = MetaDataFactory.CreateAudioMetaData(entryKey, processed.Extension, processed.Duration, processed.Bitrate);
|
|
var success = await _fileDatabase.RegisterResourceStreamingAsync(
|
|
VaultConstants.Tracks, entryKey, metaData, processed.WriteToAsync, cancellationToken);
|
|
if (!success)
|
|
{
|
|
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: vault write failed for {entryKey}; original audio preserved");
|
|
return null;
|
|
}
|
|
|
|
// Post-success stale-file cleanup for cross-format swaps. The register wrote the new
|
|
// file (e.g. .flac) and updated the index to the new extension, but the old backing
|
|
// file (e.g. .wav) is now unreferenced on disk. Delete it directly by constructing the
|
|
// old path — RemoveResourceAsync would now resolve to the new extension and delete the
|
|
// wrong file. Non-fatal: an orphaned old file is a disk-hygiene concern, not a
|
|
// playback issue (the index no longer references it).
|
|
if (oldExtension != null && oldExtension != processed.Extension)
|
|
{
|
|
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
|
if (vault != null)
|
|
{
|
|
var sanitizedKey = System.Text.RegularExpressions.Regex.Replace(entryKey, @"[^a-zA-Z0-9]", "-");
|
|
var staleFilePath = Path.Combine(vault.RootPath, $"{sanitizedKey}{oldExtension}");
|
|
try
|
|
{
|
|
if (File.Exists(staleFilePath))
|
|
File.Delete(staleFilePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: stale backing-file removal failed for {entryKey} ({staleFilePath}): {ex.Message} — new audio is live; orphaned file may remain on disk");
|
|
}
|
|
}
|
|
}
|
|
|
|
return processed.Duration;
|
|
}
|
|
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>
|
|
/// Opens a read-only, seekable stream over a track's vault audio, or null if the entry has no
|
|
/// backing file. The caller owns the stream and must dispose it. Unlike <see cref="GetAudioBinaryAsync"/>
|
|
/// this never buffers the whole file — it is the source for the streaming waveform compute. Follows
|
|
/// the FileDatabase swallow-and-return-null contract.
|
|
/// </summary>
|
|
/// <param name="trackId">Track ID (EntryKey)</param>
|
|
public async Task<Stream?> OpenAudioStreamAsync(string trackId)
|
|
{
|
|
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
|
if (vault is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var media = await vault.GetEntryStreamAsync(trackId);
|
|
return media?.Stream;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens a read-only stream over a track's vault audio together with its stored extension, or null if
|
|
/// the entry has no backing file. Same non-buffering contract as <see cref="OpenAudioStreamAsync"/>,
|
|
/// but keeps the <see cref="MediaStream.Extension"/> the caller needs to name a staging file for a
|
|
/// format-detecting consumer (the Opus transcode reopens the source by extension for ffmpeg). The
|
|
/// caller owns the returned <see cref="MediaStream"/> and must dispose it. Follows the FileDatabase
|
|
/// swallow-and-return-null contract.
|
|
/// </summary>
|
|
/// <param name="trackId">Track ID (EntryKey)</param>
|
|
public async Task<MediaStream?> OpenAudioMediaStreamAsync(string trackId)
|
|
{
|
|
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
|
if (vault is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return await vault.GetEntryStreamAsync(trackId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a track's stored audio duration from the vault index metadata WITHOUT loading the audio
|
|
/// body — the cheap counterpart of <c>GetAudioBinaryAsync(...).Duration</c>. Returns null if the
|
|
/// entry is unknown or carries no audio metadata. The streaming high-res waveform path uses this to
|
|
/// derive the duration-based bucket count, matching the value the whole-buffer path read off
|
|
/// <see cref="AudioBinary.Duration"/> so the stored datum is byte-identical.
|
|
/// </summary>
|
|
/// <param name="trackId">Track ID (EntryKey)</param>
|
|
public async Task<double?> GetAudioDurationAsync(string trackId)
|
|
{
|
|
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
|
if (vault is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var metaData = await vault.GetEntryMetadata(trackId);
|
|
return metaData is AudioMetaData audio ? audio.Duration : null;
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|
|
}
|