using DeepDrftContent.Constants; using DeepDrftContent.FileDatabase.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; namespace DeepDrftContent.Processors.Opus; /// /// Derives and persists a track's low-data Ogg Opus artifacts (Phase 18.1). Mirrors /// 's derived-artifact lifecycle: compute from the stored source, /// store in a dedicated vault keyed by EntryKey, regenerable, failure-tolerant. For one track it /// produces two entries in the vault — the Opus audio bytes and a /// combined setup-header + seek-index sidecar (§3.4a). Strictly additive: the source tracks vault /// is never touched, and a failure here leaves the track lossless-only and eligible for backfill (C2/C6). /// public sealed class OpusTranscodeService { private readonly FileDb _fileDatabase; private readonly TrackContentService _trackContent; private readonly FfmpegOpusEncoder _encoder; private readonly OpusTranscodeOptions _options; private readonly ILogger _logger; public OpusTranscodeService( FileDb fileDatabase, TrackContentService trackContent, FfmpegOpusEncoder encoder, IOptions options, ILogger logger) { _fileDatabase = fileDatabase; _trackContent = trackContent; _encoder = encoder; _options = options.Value; _logger = logger; } /// /// Reads the source audio for from the tracks vault, transcodes it /// to Ogg Opus 320, walks the encoded stream to build the seek index + capture the setup header, and /// stores the Opus bytes and the sidecar in the vault under the /// same key. Re-runnable — a second call overwrites the prior artifacts (backfill / replace-audio). /// Returns false (logged) on any failure; never throws for expected failure modes (C6). The only /// propagated exception is on genuine shutdown. /// public async Task TranscodeAndStoreAsync(string entryKey, CancellationToken ct) { // Read the source extension + duration from the vault index (no body load) and open a streamed // read over the source bytes — never the whole-buffer AudioBinary. A 92-min mix source is ~970 MB; // buffering it (and the encoded output below) was the last unconverted store-path OOM violation. var trackDuration = await _trackContent.GetAudioDurationAsync(entryKey) ?? 0.0; var sourceMedia = await _trackContent.OpenAudioMediaStreamAsync(entryKey); if (sourceMedia is null) { _logger.LogWarning("Opus transcode: no source audio in vault for {EntryKey}; skipping.", entryKey); return false; } string? sourcePath = null; string? opusPath = null; try { // Stage the source to disk in bounded chunks so ffmpeg can read it by file path/extension. // The inner finally disposes the source stream as soon as the copy is done — the read handle // is not held across the (long) encode — and guarantees disposal even if staging setup throws. try { Directory.CreateDirectory(_options.StagingPath); sourcePath = Path.Combine(_options.StagingPath, $"opus-src-{Guid.NewGuid():N}{sourceMedia.Extension}"); opusPath = Path.Combine(_options.StagingPath, $"opus-out-{Guid.NewGuid():N}{OggOpusConstants.OpusExtension}"); await using var staging = new FileStream( sourcePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true); await sourceMedia.Stream.CopyToAsync(staging, bufferSize: 81920, ct); } finally { await sourceMedia.DisposeAsync(); } if (!await _encoder.EncodeAsync(sourcePath, opusPath, ct)) return false; // encoder already logged the cause // Walk the encoded output from a streamed read in a bounded buffer (no whole-file load). The // seek index and setup header are byte-identical to the buffer walk (parity-tested). OggOpusWalk? walk; await using (var opusIn = new FileStream( opusPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: true)) { walk = await OggOpusParser.WalkAsync(opusIn, ct); } if (walk is null) { _logger.LogError( "Opus transcode: ffmpeg produced output but the Ogg stream could not be walked for {EntryKey}; " + "no artifacts stored.", entryKey); return false; } await EnsureVaultAsync(); // Bitrate from the output file length + duration — both available without buffering the bytes. var opusLength = new FileInfo(opusPath).Length; var opusBitrate = trackDuration > 0 ? (int)(opusLength * 8 / trackDuration / 1000) : _options.BitrateKbps; // Store the audio first, then the sidecar. If the sidecar write fails the Opus bytes are // present but unseekable — treat that as a failed derive (return false) so a backfill re-runs // it; do not leave a half-derived track that the delivery layer would treat as complete. var audioMeta = MetaDataFactory.CreateAudioMetaData( OpusAudioKey(entryKey), OggOpusConstants.OpusExtension, trackDuration, opusBitrate); var stagedOpusPath = opusPath; var audioStored = await _fileDatabase.RegisterResourceStreamingAsync( VaultConstants.TrackOpus, OpusAudioKey(entryKey), audioMeta, (destination, token) => AudioStoreStream.CopyFileAsync(stagedOpusPath, destination, token), ct); if (!audioStored) { _logger.LogError("Opus transcode: vault write of Opus audio failed for {EntryKey}.", entryKey); return false; } // The sidecar is the setup header (a few KB) plus the seek index (~16 bytes per 0.5 s bucket); // it is inherently bounded and already in managed memory, so the whole-buffer write is correct. var sidecar = new OpusSidecar(walk.SetupHeaderBytes, walk.SeekIndex).ToBytes(); var sidecarBinary = new MediaBinary(new MediaBinaryParams( sidecar, sidecar.Length, OggOpusConstants.SidecarExtension)); var sidecarStored = await _fileDatabase.RegisterResourceAsync( VaultConstants.TrackOpus, OpusSidecarKey(entryKey), sidecarBinary); if (!sidecarStored) { _logger.LogError("Opus transcode: vault write of sidecar failed for {EntryKey}.", entryKey); return false; } _logger.LogInformation( "Opus transcode complete for {EntryKey}: {OpusBytes} bytes, {Points} seek points, {Duration:F1}s.", entryKey, opusLength, walk.SeekIndex.Points.Count, walk.SeekIndex.TotalDurationSeconds); return true; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { _logger.LogError(ex, "Opus transcode failed for {EntryKey}; track stays lossless-only.", entryKey); return false; } finally { if (sourcePath is not null) TryDelete(sourcePath); if (opusPath is not null) TryDelete(opusPath); } } /// The vault entry key under which a track's Opus audio bytes are stored. public static string OpusAudioKey(string entryKey) => entryKey; /// The vault entry key under which a track's setup-header + seek-index sidecar is stored. public static string OpusSidecarKey(string entryKey) => $"{entryKey}-sidecar"; private async Task EnsureVaultAsync() { // The TrackOpus vault is created at host startup (Startup.cs), so this guard is normally a // no-op for the upload path. It is retained for the backfill path, which may run via a // standalone CLI or a host that skips vault pre-creation, where the vault might not exist. if (!_fileDatabase.HasVault(VaultConstants.TrackOpus)) await _fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio); } private void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch (Exception ex) { _logger.LogWarning(ex, "Opus transcode: failed to delete staging file {Path}.", path); } } }