diff --git a/DeepDrftAPI/Startup.cs b/DeepDrftAPI/Startup.cs index b45fe56..2b66f0f 100644 --- a/DeepDrftAPI/Startup.cs +++ b/DeepDrftAPI/Startup.cs @@ -77,6 +77,11 @@ namespace DeepDrftAPI builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Opus delivery format resolution + sidecar lookup (Phase 18.2). The seam 18.3 calls behind + // the ?format= stream param and the sidecar path. Stateless over the FileDatabase + content + // service singletons; the lossless branch reuses the existing read path unchanged (C2). + builder.Services.AddSingleton(); + return Task.CompletedTask; } diff --git a/DeepDrftContent/Processors/Opus/ResolvedAudio.cs b/DeepDrftContent/Processors/Opus/ResolvedAudio.cs new file mode 100644 index 0000000..85b3813 --- /dev/null +++ b/DeepDrftContent/Processors/Opus/ResolvedAudio.cs @@ -0,0 +1,23 @@ +using DeepDrftContent.FileDatabase.Models; +using DeepDrftModels.Enums; + +namespace DeepDrftContent.Processors.Opus; + +/// +/// The outcome of resolving a track + requested to a concrete artifact +/// (Phase 18.2). Carries the bytes, the content-type that matches what was actually returned, +/// and the format actually served — which may differ from the requested one when the C2 fallback fires +/// (Opus requested, no Opus artifact → the lossless artifact + its content-type). The delivery layer +/// (18.3) sets the response Content-Type from so the eventual decoder +/// picks the right decoder for the bytes it receives, not the bytes the listener asked for. +/// +/// The resolved audio artifact (never null when a resolution succeeds). +/// The MIME type of (e.g. audio/ogg for Opus, +/// or the source's real MIME for lossless). +/// The format actually returned. Equal to the requested format on a direct +/// hit; when an Opus request fell back. +public sealed record ResolvedAudio(AudioBinary Audio, string ContentType, AudioFormat ResolvedFormat) +{ + /// True when an Opus request was served the lossless artifact because no Opus existed (C2). + public bool DidFallBack(AudioFormat requested) => requested != ResolvedFormat; +} diff --git a/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs b/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs new file mode 100644 index 0000000..070e820 --- /dev/null +++ b/DeepDrftContent/Processors/Opus/TrackFormatResolver.cs @@ -0,0 +1,91 @@ +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftModels.Enums; +using Microsoft.Extensions.Logging; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftContent.Processors.Opus; + +/// +/// The server-side format resolution + sidecar lookup seam (Phase 18.2). Given a track's +/// EntryKey and a requested , returns the correct audio artifact and the +/// content-type that matches it; given an EntryKey, returns the Opus seek/setup sidecar bytes. +/// Downstream waves call this — 18.3 wires it behind the ?format= stream param and serves the +/// sidecar over HTTP; this wave delivers only the seam, not the HTTP surface. +/// +/// Additive and non-breaking (C2): the lossless branch reads the source exactly as the existing stream +/// path does (via ), and an Opus request for a track +/// with no Opus artifact falls back to lossless rather than failing. Mirrors the +/// derived-artifact lookup precedent: read from the dedicated vault, +/// swallow misses to null (FileDatabase convention), let the caller decide. +/// +/// +public sealed class TrackFormatResolver +{ + private readonly FileDb _fileDatabase; + private readonly TrackContentService _trackContentService; + private readonly ILogger _logger; + + public TrackFormatResolver( + FileDb fileDatabase, + TrackContentService trackContentService, + ILogger logger) + { + _fileDatabase = fileDatabase; + _trackContentService = trackContentService; + _logger = logger; + } + + /// + /// Resolves + to the audio artifact to + /// serve plus its content-type. resolves the source artifact in the + /// tracks vault with its real MIME (WAV/MP3/FLAC). resolves the + /// derived Opus artifact (audio/ogg) when present, and falls back to lossless + /// when it is not (C2). Returns null only when even the lossless source is missing — i.e. the track has + /// no audio at all (an unknown key or a genuinely empty vault), the one case the caller treats as 404. + /// + public async Task ResolveAsync(string entryKey, AudioFormat requestedFormat) + { + if (requestedFormat == AudioFormat.Opus) + { + var opus = await _fileDatabase.LoadResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey)); + if (opus is not null) + return new ResolvedAudio(opus, MimeTypeExtensions.GetMimeType(opus.Extension), AudioFormat.Opus); + + // C2 fallback: no Opus artifact yet (legacy row, not backfilled, or transcode failed). Degrade + // to lossless rather than 404 — Opus is strictly additive; its absence never means "no audio". + _logger.LogInformation( + "Opus requested for {EntryKey} but no Opus artifact exists; falling back to lossless.", entryKey); + } + + return await ResolveLosslessAsync(entryKey); + } + + /// + /// Resolves the lossless source artifact and its real MIME — the existing read path, unchanged. Shared + /// by the explicit-lossless branch and the Opus fallback so both produce identical bytes + content-type. + /// + private async Task ResolveLosslessAsync(string entryKey) + { + var source = await _trackContentService.GetAudioBinaryAsync(entryKey); + if (source is null) + return null; + + return new ResolvedAudio(source, MimeTypeExtensions.GetMimeType(source.Extension), AudioFormat.Lossless); + } + + /// + /// Returns the Opus setup-header + seek-index sidecar bytes for , or null + /// when no sidecar is stored (no Opus artifact yet, or an older derive predating the sidecar). 18.3 + /// serves these on their own path; 18.4 fetches them once on track load and parses them into the + /// client's OpusSeekData. The bytes are the raw blob + /// ([uint32 setupHeaderLength][setup-header][seek-index]) exactly as 18.1 stored them. + /// + public async Task GetOpusSidecarAsync(string entryKey) + { + var sidecar = await _fileDatabase.LoadResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey)); + return sidecar?.Buffer; + } +} diff --git a/DeepDrftModels/Enums/AudioFormat.cs b/DeepDrftModels/Enums/AudioFormat.cs new file mode 100644 index 0000000..9b95067 --- /dev/null +++ b/DeepDrftModels/Enums/AudioFormat.cs @@ -0,0 +1,24 @@ +namespace DeepDrftModels.Enums; + +/// +/// The delivery format a listener requests for a track's audio (Phase 18). One TrackEntity / +/// EntryKey addresses both renderings — "one source, multiple views" applied to delivery (C5). +/// Lives here, not in the content library, because it is a cross-boundary contract: the API stream +/// endpoint (18.3) parses it off the ?format= query param, the WASM client (18.4 / 18.6) selects +/// it, and the content-side resolver (18.2) resolves it to bytes — all three reference one enum. +/// +public enum AudioFormat +{ + /// + /// The existing source artifact in the tracks vault, served byte-for-byte with its real MIME + /// (WAV/MP3/FLAC — do not assume WAV). The universal, always-present rendering. + /// + Lossless, + + /// + /// The derived low-data Ogg Opus 320 artifact in the track-opus vault (audio/ogg). A + /// best-effort derived artifact: when absent (not yet transcoded, or transcode failed) a request for + /// it falls back to rather than 404ing (C2). + /// + Opus +} diff --git a/DeepDrftTests/TrackFormatResolverTests.cs b/DeepDrftTests/TrackFormatResolverTests.cs new file mode 100644 index 0000000..8aa08f2 --- /dev/null +++ b/DeepDrftTests/TrackFormatResolverTests.cs @@ -0,0 +1,180 @@ +using DeepDrftContent; +using DeepDrftContent.Constants; +using DeepDrftContent.FileDatabase.Models; +using DeepDrftContent.Processors; +using DeepDrftContent.Processors.Opus; +using DeepDrftModels.Enums; +using Microsoft.Extensions.Logging.Abstractions; +using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase; + +namespace DeepDrftTests; + +/// +/// Integration tests for the Phase 18.2 format resolution + sidecar lookup seam +/// () over a real . They exercise the four +/// resolution branches the brief specifies — lossless, Opus hit, the C2 Opus→lossless fallback, and +/// the unknown-track miss — plus sidecar hit/miss. Artifacts are seeded into the vaults exactly as +/// 18.1's stores them (Opus audio under the bare EntryKey, sidecar +/// under the -sidecar-suffixed key, the source in the tracks vault), so the test is faithful +/// to the real storage convention rather than a stand-in. +/// +[TestFixture] +public class TrackFormatResolverTests +{ + private string _testDir = string.Empty; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), "TrackFormatResolverTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_testDir, recursive: true); } + catch { /* Best-effort cleanup — ignore failures */ } + } + + private static TrackFormatResolver CreateResolver(FileDb fileDatabase) + { + // The resolver only calls GetAudioBinaryAsync (a vault read), which never touches the router — + // but TrackContentService requires one, so supply a real router with real processors. + var router = new AudioProcessorRouter( + new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()); + var contentService = new TrackContentService(fileDatabase, router); + return new TrackFormatResolver( + fileDatabase, contentService, NullLogger.Instance); + } + + // Seeds a source artifact in the tracks vault with the given extension, mirroring how the upload path + // stores the original bytes (WAV/MP3/FLAC). Returns the bytes for downstream identity assertions. + private static async Task SeedSourceAsync(FileDb db, string entryKey, string extension) + { + await db.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio); + var bytes = new byte[] { 1, 2, 3, 4, 5 }; + var audio = new AudioBinary(new AudioBinaryParams(bytes, bytes.Length, extension, 12.0, 1411)); + var ok = await db.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audio); + Assert.That(ok, Is.True, "source seed must succeed"); + return bytes; + } + + // Seeds the Opus audio + sidecar in the track-opus vault exactly as OpusTranscodeService does: + // audio under OpusAudioKey (the bare EntryKey) with the .opus extension, sidecar under OpusSidecarKey. + private static async Task<(byte[] opus, byte[] sidecar)> SeedOpusAsync(FileDb db, string entryKey) + { + await db.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio); + + var opusBytes = new byte[] { 9, 9, 9 }; + var opusAudio = new AudioBinary(new AudioBinaryParams( + opusBytes, opusBytes.Length, OggOpusConstants.OpusExtension, 12.0, 320)); + var audioOk = await db.RegisterResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opusAudio); + Assert.That(audioOk, Is.True, "opus audio seed must succeed"); + + var sidecarBytes = new byte[] { 7, 7, 7, 7 }; + var sidecar = new MediaBinary(new MediaBinaryParams( + sidecarBytes, sidecarBytes.Length, OggOpusConstants.SidecarExtension)); + var sidecarOk = await db.RegisterResourceAsync( + VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar); + Assert.That(sidecarOk, Is.True, "sidecar seed must succeed"); + + return (opusBytes, sidecarBytes); + } + + [Test] + public async Task ResolveAsync_Lossless_ReturnsSourceArtifactWithItsRealMime() + { + var db = (await FileDb.FromAsync(_testDir))!; + const string entryKey = "lossless-track"; + // A FLAC source — proves the lossless branch does NOT assume WAV: the content-type tracks the + // stored extension's MIME, not a hard-coded audio/wav. + var bytes = await SeedSourceAsync(db, entryKey, ".flac"); + var resolver = CreateResolver(db); + + var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Lossless); + + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Lossless)); + Assert.That(resolved.ContentType, Is.EqualTo("audio/flac")); + Assert.That(resolved.Audio.Buffer, Is.EqualTo(bytes)); + Assert.That(resolved.DidFallBack(AudioFormat.Lossless), Is.False); + } + + [Test] + public async Task ResolveAsync_OpusWhenArtifactExists_ReturnsOpusWithOggContentType() + { + var db = (await FileDb.FromAsync(_testDir))!; + const string entryKey = "opus-track"; + await SeedSourceAsync(db, entryKey, ".wav"); + var (opusBytes, _) = await SeedOpusAsync(db, entryKey); + var resolver = CreateResolver(db); + + var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Opus); + + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Opus)); + Assert.That(resolved.ContentType, Is.EqualTo("audio/ogg")); + Assert.That(resolved.Audio.Buffer, Is.EqualTo(opusBytes)); + Assert.That(resolved.DidFallBack(AudioFormat.Opus), Is.False); + } + + [Test] + public async Task ResolveAsync_OpusWhenNoArtifact_FallsBackToLosslessNeverNull() + { + var db = (await FileDb.FromAsync(_testDir))!; + const string entryKey = "no-opus-track"; + // Source exists; no Opus artifact has been derived. The C2 rule: degrade to lossless, never 404. + var bytes = await SeedSourceAsync(db, entryKey, ".wav"); + var resolver = CreateResolver(db); + + var resolved = await resolver.ResolveAsync(entryKey, AudioFormat.Opus); + + Assert.That(resolved, Is.Not.Null, "Opus absence must degrade to lossless, not null/404"); + Assert.That(resolved!.ResolvedFormat, Is.EqualTo(AudioFormat.Lossless), + "the resolved format must reflect what was actually returned"); + Assert.That(resolved.ContentType, Is.EqualTo("audio/wav"), + "a fallback returns the lossless content-type so the decoder picks the right decoder"); + Assert.That(resolved.Audio.Buffer, Is.EqualTo(bytes)); + Assert.That(resolved.DidFallBack(AudioFormat.Opus), Is.True); + } + + [Test] + public async Task ResolveAsync_UnknownTrack_ReturnsNull() + { + var db = (await FileDb.FromAsync(_testDir))!; + var resolver = CreateResolver(db); + + // No source at all — the one case the caller maps to 404. Holds for both requested formats: + // Opus falls back to lossless, finds nothing, and returns null too. + Assert.That(await resolver.ResolveAsync("ghost", AudioFormat.Lossless), Is.Null); + Assert.That(await resolver.ResolveAsync("ghost", AudioFormat.Opus), Is.Null); + } + + [Test] + public async Task GetOpusSidecarAsync_WhenPresent_ReturnsBytes() + { + var db = (await FileDb.FromAsync(_testDir))!; + const string entryKey = "sidecar-track"; + var (_, sidecarBytes) = await SeedOpusAsync(db, entryKey); + var resolver = CreateResolver(db); + + var bytes = await resolver.GetOpusSidecarAsync(entryKey); + + Assert.That(bytes, Is.Not.Null); + Assert.That(bytes, Is.EqualTo(sidecarBytes)); + } + + [Test] + public async Task GetOpusSidecarAsync_WhenAbsent_ReturnsNull() + { + var db = (await FileDb.FromAsync(_testDir))!; + var resolver = CreateResolver(db); + + // No Opus artifacts derived for this track — the sidecar lookup misses to null, not an exception. + var bytes = await resolver.GetOpusSidecarAsync("no-sidecar-track"); + + Assert.That(bytes, Is.Null); + } +}