using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Services; using DeepDrftContent.FileDatabase.Models; using DeepDrftContent.Processors; using DeepDrftModels.Entities; namespace DeepDrftContent; /// /// Service for managing tracks in both SQL and FileDatabase /// 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; } /// /// 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. /// /// Path to the audio file /// Name of the track /// Artist name /// Optional album name /// Optional genre /// Optional release date /// Optional original browser filename captured at upload time /// The track entity with generated ID and media path public async Task 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; } } /// /// Backward-compatible shim — delegates to . The router accepts WAV /// alongside MP3 and FLAC, so this carries no WAV-specific logic of its own. /// public Task 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); /// /// Swaps the audio bytes for an existing track in place: processes a new audio file and /// re-registers it under the SAME 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 duration 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. /// public async Task 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; } } /// /// Retrieves audio binary from FileDatabase /// /// Track ID (EntryKey) /// Audio binary or null if not found public async Task GetAudioBinaryAsync(string trackId) { return await _fileDatabase.LoadResourceAsync(VaultConstants.Tracks, trackId); } /// /// 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 /// this never buffers the whole file — it is the source for the streaming waveform compute. Follows /// the FileDatabase swallow-and-return-null contract. /// /// Track ID (EntryKey) public async Task OpenAudioStreamAsync(string trackId) { var vault = _fileDatabase.GetVault(VaultConstants.Tracks); if (vault is null) { return null; } var media = await vault.GetEntryStreamAsync(trackId); return media?.Stream; } /// /// Reads a track's stored audio duration from the vault index metadata WITHOUT loading the audio /// body — the cheap counterpart of GetAudioBinaryAsync(...).Duration. 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 /// so the stored datum is byte-identical. /// /// Track ID (EntryKey) public async Task 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; } /// /// Checks if FileDatabase is available and tracks vault exists /// public bool IsFileDatabaseReady() { return _fileDatabase.HasVault(VaultConstants.Tracks); } /// /// Initializes the tracks vault if it doesn't exist /// public async Task InitializeTracksVaultAsync() { if (!_fileDatabase.HasVault(VaultConstants.Tracks)) { await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio); } } }