Wire Opus end-to-end playback + Backfill-Opus action (Phase 18.5)
Player picks Opus when the browser can decode it and a sidecar exists (else lossless), injecting the sidecar before stream init; seek reuses the same format. Adds the Backfill-Opus bulk API endpoint + CMS action.
This commit is contained in:
@@ -269,6 +269,27 @@ public class TrackController : ControllerBase
|
||||
return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped });
|
||||
}
|
||||
|
||||
// POST api/track/opus/backfill ([ApiKeyAuthorize], no body)
|
||||
// Backfill-Opus (18.5, OQ4): enqueue a background Opus derive for every track lacking a complete Opus
|
||||
// artifact (audio + sidecar). Mirrors the duration-backfill posture — enqueue-only and non-blocking, the
|
||||
// transcodes run on the shared serial worker. Idempotent: a re-run only schedules tracks still missing
|
||||
// Opus. Returns { enqueued, skipped }. Declared in the literal-route block (before "{trackId}") so the
|
||||
// "opus/backfill" segment is never treated as a trackId; distinct shape from "{trackId}/opus" (per-track).
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("opus/backfill")]
|
||||
public async Task<ActionResult> BackfillOpus(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _unifiedService.BackfillOpusAsync(cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("BackfillOpus failed: {Error}", error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
return Ok(new { enqueued = result.Value.Enqueued, skipped = result.Value.Skipped });
|
||||
}
|
||||
|
||||
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
|
||||
// proxies the upload here so it never touches the vault disk path or SQL directly.
|
||||
@@ -875,6 +896,32 @@ public class TrackController : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// POST api/track/{trackId}/opus ([ApiKeyAuthorize])
|
||||
// Per-track Opus (re)derive trigger (18.5): schedule a single track's background transcode. Enqueue-only
|
||||
// and non-blocking — the transcode runs on the shared serial worker; this returns as soon as it is
|
||||
// scheduled. Re-runnable: overwrites any prior artifact in place. trackId is the EntryKey. 404 when the
|
||||
// track id is unknown. The "opus" literal suffix keeps this distinct from the audio/waveform routes and
|
||||
// from the parameterized PUT "{trackId}". Returns 202 Accepted — the work is queued, not done inline.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{trackId}/opus")]
|
||||
public async Task<ActionResult> GenerateOpus(string trackId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _unifiedService.EnqueueOpusAsync(trackId, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_logger.LogError("GenerateOpus failed for {TrackId}: {Error}", trackId, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("{trackId}")]
|
||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||
|
||||
@@ -2,6 +2,7 @@ using DeepDrftAPI.Services.Opus;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
@@ -41,6 +42,7 @@ public class UnifiedTrackService
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly IOpusTranscodeQueue _opusTranscodeQueue;
|
||||
private readonly TrackFormatResolver _formatResolver;
|
||||
private readonly ILogger<UnifiedTrackService> _logger;
|
||||
|
||||
public UnifiedTrackService(
|
||||
@@ -49,6 +51,7 @@ public class UnifiedTrackService
|
||||
FileDb fileDatabase,
|
||||
WaveformProfileService waveformProfileService,
|
||||
IOpusTranscodeQueue opusTranscodeQueue,
|
||||
TrackFormatResolver formatResolver,
|
||||
ILogger<UnifiedTrackService> logger)
|
||||
{
|
||||
_contentTrackContentService = contentTrackContentService;
|
||||
@@ -56,6 +59,7 @@ public class UnifiedTrackService
|
||||
_fileDatabase = fileDatabase;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_opusTranscodeQueue = opusTranscodeQueue;
|
||||
_formatResolver = formatResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -395,6 +399,69 @@ public class UnifiedTrackService
|
||||
return ResultContainer<(int, int)>.CreatePassResult((updated, skipped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill-Opus (18.5, OQ4): enqueue a background Opus derive for every non-deleted track that lacks a
|
||||
/// complete Opus artifact (missing audio OR missing sidecar — a half-derived track is treated as missing
|
||||
/// and re-derived). Mirrors the duration-backfill posture: enumerate SQL rows, check each against the
|
||||
/// <c>track-opus</c> vault, schedule the misses. Enqueue-only and non-blocking — the actual transcodes run
|
||||
/// on the shared background worker, serially (the same queue the upload/replace paths feed), so this
|
||||
/// returns as soon as the misses are scheduled rather than waiting on CPU-heavy transcodes. Idempotent:
|
||||
/// a re-run only enqueues tracks still missing Opus, and already-queued/in-flight derives simply overwrite
|
||||
/// in place. Returns (enqueued, skipped) — skipped = tracks that already have a complete Opus artifact.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<(int Enqueued, int Skipped)>> BackfillOpusAsync(CancellationToken ct)
|
||||
{
|
||||
var all = await _sqlTrackService.GetAll();
|
||||
if (!all.Success || all.Value is null)
|
||||
{
|
||||
var error = all.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("BackfillOpusAsync: failed to load tracks: {Error}", error);
|
||||
return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
|
||||
}
|
||||
|
||||
var enqueued = 0;
|
||||
var skipped = 0;
|
||||
foreach (var track in all.Value)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (await _formatResolver.HasOpusAsync(track.EntryKey))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
_opusTranscodeQueue.Enqueue(track.EntryKey);
|
||||
enqueued++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackfillOpusAsync complete: {Enqueued} enqueued, {Skipped} already had Opus.",
|
||||
enqueued, skipped);
|
||||
return ResultContainer<(int, int)>.CreatePassResult((enqueued, skipped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-track Opus (re)derive trigger (18.5): schedule a background transcode for one track. Returns false
|
||||
/// only when the track id is unknown; the enqueue itself is non-blocking and best-effort, like the bulk
|
||||
/// backfill. Re-runnable — overwrites any prior artifact in place.
|
||||
/// </summary>
|
||||
public async Task<Result> EnqueueOpusAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _sqlTrackService.GetByEntryKey(entryKey);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("EnqueueOpusAsync: lookup failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return Result.CreateFailResult("Failed to load track.");
|
||||
}
|
||||
|
||||
if (lookup.Value is null)
|
||||
return Result.CreateFailResult(TrackNotFoundMessage);
|
||||
|
||||
_opusTranscodeQueue.Enqueue(entryKey);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
|
||||
/// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
|
||||
|
||||
@@ -88,4 +88,23 @@ public sealed class TrackFormatResolver
|
||||
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey));
|
||||
return sidecar?.Buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether <paramref name="entryKey"/> already has a complete Opus derive — both the audio bytes
|
||||
/// AND the seek/setup sidecar present in the <c>track-opus</c> vault. The Backfill-Opus pass (18.5) uses
|
||||
/// this to enqueue only tracks that are missing or half-derived (audio without sidecar = unseekable, so
|
||||
/// treated as incomplete and re-derived). Both halves are required because the transcode stores them in
|
||||
/// sequence and a sidecar-write failure leaves a track the delivery layer must not treat as Opus-ready.
|
||||
/// </summary>
|
||||
public async Task<bool> HasOpusAsync(string entryKey)
|
||||
{
|
||||
var audio = await _fileDatabase.LoadResourceAsync<AudioBinary>(
|
||||
VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey));
|
||||
if (audio is null)
|
||||
return false;
|
||||
|
||||
var sidecar = await _fileDatabase.LoadResourceAsync<MediaBinary>(
|
||||
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey));
|
||||
return sidecar is not null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,26 @@
|
||||
<span>Backfill High-res (@MissingHighResCount)</span>
|
||||
}
|
||||
</MudButton>
|
||||
@* Backfill-Opus (Phase 18.5). Unlike the two waveform buttons, the Opus derive runs on a
|
||||
server-side background worker: the API decides which tracks lack Opus and enqueues them, so
|
||||
there is no client-side "missing N" count to gate on and no per-track progress to render — the
|
||||
action schedules the work and reports the (enqueued / skipped) outcome. Re-runnable: a second
|
||||
press only enqueues tracks still missing Opus. Disabled while a press is in flight. *@
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || _opusBackfillRunning)"
|
||||
OnClick="BackfillOpusAsync">
|
||||
@if (_opusBackfillRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Scheduling…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Backfill Opus</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@@ -150,6 +170,11 @@
|
||||
private int _highResBulkTotal;
|
||||
private int _highResBulkDone;
|
||||
|
||||
// Local state for the "Backfill Opus" action. The Opus derive is server-side and background-queued, so
|
||||
// there is no client-side per-track loop or progress total — this flag only guards the button while the
|
||||
// single scheduling call is in flight.
|
||||
private bool _opusBackfillRunning;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Seed the active tab from ?medium= so a catalogue card deep-links straight to its medium. Panel 0
|
||||
@@ -291,4 +316,50 @@
|
||||
Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kick off the catalogue-wide Backfill-Opus pass. The API enumerates the tracks lacking a complete Opus
|
||||
/// artifact, enqueues a background derive for each, and returns the (enqueued, skipped) counts. This is a
|
||||
/// single scheduling call — the transcodes run server-side afterward — so there is no per-track progress
|
||||
/// to render here, just a busy flag and a result snackbar. Re-runnable: a second press only schedules
|
||||
/// tracks still missing Opus.
|
||||
/// </summary>
|
||||
private async Task BackfillOpusAsync()
|
||||
{
|
||||
_opusBackfillRunning = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.BackfillOpusAsync();
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to start the Opus backfill.";
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var (enqueued, skipped) = (result.Value.Enqueued, result.Value.Skipped);
|
||||
if (enqueued == 0)
|
||||
{
|
||||
Snackbar.Add($"All {skipped} track(s) already have Opus — nothing to backfill.", Severity.Info);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(
|
||||
$"Scheduled {enqueued} Opus transcode(s) in the background ({skipped} already had Opus). " +
|
||||
"They will appear as each finishes.",
|
||||
Severity.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Opus backfill failed to start");
|
||||
Snackbar.Add("Failed to start the Opus backfill.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_opusBackfillRunning = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,6 +765,45 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<OpusBackfillResult>> BackfillOpusAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.PostAsync("api/track/opus/backfill", null, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for Opus backfill");
|
||||
return ResultContainer<OpusBackfillResult>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("Content API Opus backfill failed: {Status} {Body}", (int)response.StatusCode, body);
|
||||
return ResultContainer<OpusBackfillResult>.CreateFailResult("Failed to start the Opus backfill.");
|
||||
}
|
||||
|
||||
OpusBackfillResult payload;
|
||||
try
|
||||
{
|
||||
payload = await response.Content.ReadFromJsonAsync<OpusBackfillResult>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize Opus backfill response from Content API");
|
||||
return ResultContainer<OpusBackfillResult>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
return ResultContainer<OpusBackfillResult>.CreatePassResult(payload);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
@@ -152,6 +152,15 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Trigger the catalogue-wide Backfill-Opus pass via <c>POST api/track/opus/backfill</c> (Phase 18.5).
|
||||
/// The API enqueues a background Opus derive for every track lacking a complete Opus artifact and returns
|
||||
/// the (enqueued, skipped) counts. Enqueue-only — the transcodes run server-side on a serial background
|
||||
/// worker, so this call returns as soon as the work is scheduled, not when transcoding finishes. The
|
||||
/// <c>Enqueued</c> count is how many derives were scheduled; <c>Skipped</c> is how many already had Opus.
|
||||
/// </summary>
|
||||
Task<ResultContainer<OpusBackfillResult>> BackfillOpusAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -160,3 +169,11 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a Backfill-Opus pass (Phase 18.5): how many tracks had a background derive scheduled
|
||||
/// (<paramref name="Enqueued"/>) and how many were skipped because they already carry a complete Opus
|
||||
/// artifact (<paramref name="Skipped"/>). Both are counts of tracks, not finished transcodes — the work
|
||||
/// runs asynchronously on the API's background worker after this returns.
|
||||
/// </summary>
|
||||
public readonly record struct OpusBackfillResult(int Enqueued, int Skipped);
|
||||
|
||||
@@ -70,6 +70,36 @@ public class AudioInteropService : IAsyncDisposable
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.initializeStreaming", playerId, totalStreamLength, contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probes whether this browser can decode Ogg Opus via <c>decodeAudioData</c> (Safari < 18.4 cannot).
|
||||
/// Phase 18 capability gate (OQ2): the player only requests Opus when this returns true, otherwise it
|
||||
/// stays on the universal lossless path (AC7 — no listener ever gets silence over a codec gap). Probe
|
||||
/// failures degrade to <c>false</c> (assume incapable) so an interop error can never silence playback.
|
||||
/// </summary>
|
||||
public async Task<bool> CanDecodeOggOpus(string playerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _jsRuntime.InvokeAsync<bool>("DeepDrftAudio.canDecodeOggOpus");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands the raw Opus seek/setup sidecar bytes (setup header + granule→byte seek index) to the JS player
|
||||
/// so the next Opus stream's decoder has them BEFORE init (the 18.4 set-before-init contract). The player
|
||||
/// parses and stashes them; <see cref="InitializeStreaming"/> applies them when it builds the Opus decoder.
|
||||
/// Must be called before <see cref="InitializeStreaming"/> on an Opus stream. Returns the parse result —
|
||||
/// a failure means the bytes were not a valid sidecar, and the caller falls back to lossless.
|
||||
/// </summary>
|
||||
public async Task<AudioOperationResult> SetOpusSidecar(string playerId, byte[] sidecarBytes)
|
||||
{
|
||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.setOpusSidecar", playerId, sidecarBytes);
|
||||
}
|
||||
|
||||
public async Task<StreamingResult> ProcessStreamingChunk(string playerId, byte[] audioChunk)
|
||||
{
|
||||
return await InvokeJsAsync<StreamingResult>("DeepDrftAudio.processStreamingChunk", playerId, audioChunk);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using System.Buffers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -33,6 +34,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
||||
private string? _currentTrackId;
|
||||
|
||||
// The delivery format the active load resolved to (Phase 18). Captured once per LoadTrackStreaming and
|
||||
// reused by the seek-beyond-buffer re-fetch so the Range continuation requests the SAME artifact the
|
||||
// initial stream did — a seek must never switch formats mid-track (the JS decoder, the cached setup
|
||||
// header, and the byte offsets all belong to one artifact). Defaults to Lossless until a load resolves.
|
||||
private AudioFormat _currentFormat = AudioFormat.Lossless;
|
||||
|
||||
// Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at
|
||||
// most one bucketed play event per session, behind the engagement floor. Attached after construction
|
||||
// by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no
|
||||
@@ -174,11 +181,18 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Resolve the delivery format for this load BEFORE requesting bytes (Phase 18, default policy
|
||||
// OQ2). When Opus is chosen the sidecar is fetched and injected into the JS player here, ahead of
|
||||
// InitializeStreaming, honouring the 18.4 set-before-init contract. The result is captured so the
|
||||
// seek-beyond-buffer re-fetch reuses the same artifact.
|
||||
_currentFormat = await ResolveStreamFormatAsync(track.EntryKey, loadCts.Token);
|
||||
|
||||
// Pass the streaming token to the HTTP layer so a navigation/track switch
|
||||
// aborts the server connection instead of leaving it draining bytes.
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||
track.EntryKey,
|
||||
byteOffset: 0,
|
||||
format: _currentFormat,
|
||||
cancellationToken: loadCts.Token);
|
||||
if (!mediaResult.Success)
|
||||
{
|
||||
@@ -250,6 +264,50 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves which delivery format this load should request (Phase 18 default policy, OQ2): Opus when the
|
||||
/// browser can decode Ogg Opus AND a sidecar exists for the track, otherwise lossless. When Opus is
|
||||
/// chosen the sidecar is injected into the JS player here (set-before-init, the 18.4 contract) so the
|
||||
/// decoder has its setup header + seek index before <c>InitializeStreaming</c> builds it.
|
||||
/// <para>
|
||||
/// This is the single, deliberately-overridable seam for the listener quality preference (wave 18.6).
|
||||
/// 18.6 overrides this to honour the user's "streaming quality" toggle — returning lossless when the
|
||||
/// listener picked it, and otherwise falling through to this capability-gated default. The capability
|
||||
/// gate (AC7) and the sidecar-absent → lossless fallback (C2) stay here so any override inherits both:
|
||||
/// a browser that cannot decode Opus, or a track with no sidecar, always lands on lossless and plays.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
protected virtual async Task<AudioFormat> ResolveStreamFormatAsync(string entryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
// Capability gate first (AC7): never hand Ogg Opus to a browser that cannot decode it.
|
||||
if (!await _audioInterop.CanDecodeOggOpus(PlayerId))
|
||||
{
|
||||
return AudioFormat.Lossless;
|
||||
}
|
||||
|
||||
// The sidecar must be present (and parseable by the JS decoder) to seek an Opus stream. Its absence
|
||||
// means the track has no Opus artifact yet (legacy / not backfilled / transcode failed) — request
|
||||
// lossless rather than Opus-without-a-sidecar (the server would C2-fall-back anyway, but asking for
|
||||
// lossless keeps the request honest and avoids a wasted Opus-then-fallback round-trip).
|
||||
var sidecar = await _trackMediaClient.GetOpusSidecarAsync(entryKey, cancellationToken);
|
||||
if (!sidecar.Success || sidecar.Value is not { Length: > 0 } sidecarBytes)
|
||||
{
|
||||
return AudioFormat.Lossless;
|
||||
}
|
||||
|
||||
// Inject BEFORE InitializeStreaming (the set-before-init contract). A parse failure here means the
|
||||
// bytes are not a usable sidecar — fall back to lossless so a malformed sidecar never breaks playback.
|
||||
var injected = await _audioInterop.SetOpusSidecar(PlayerId, sidecarBytes);
|
||||
if (!injected.Success)
|
||||
{
|
||||
_logger.LogWarning("Opus sidecar for {EntryKey} failed to parse ({Error}); falling back to lossless.",
|
||||
entryKey, injected.Error);
|
||||
return AudioFormat.Lossless;
|
||||
}
|
||||
|
||||
return AudioFormat.Opus;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches and decodes the track's waveform loudness profile, then notifies state so the
|
||||
/// seek zone re-renders with real bars. Best-effort: a 404 (no stored profile) or any other
|
||||
@@ -544,10 +602,15 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
CurrentTime = seekPosition;
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Request new stream from offset
|
||||
// Request new stream from offset. Reuse the format the initial load resolved to (_currentFormat):
|
||||
// an Opus seek must come back as Opus bytes so the cached setup header + page-aligned byteOffset
|
||||
// (resolved by the JS decoder's index-based calculateByteOffset) match the continuation. The
|
||||
// offset itself is computed JS-side from the Opus seek index for Opus, exactly as it is from the
|
||||
// WAV header for lossless — one seam, format-appropriate math (AC9 / §3.4a C).
|
||||
var mediaResult = await _trackMediaClient.GetTrackMedia(
|
||||
_currentTrackId,
|
||||
byteOffset,
|
||||
format: _currentFormat,
|
||||
cancellationToken: seekCts.Token);
|
||||
if (!mediaResult.Success || mediaResult.Value == null)
|
||||
{
|
||||
@@ -653,6 +716,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
_streamingPlaybackStarted = false;
|
||||
IsSeekingBeyondBuffer = false;
|
||||
_currentTrackId = null;
|
||||
_currentFormat = AudioFormat.Lossless;
|
||||
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Text;
|
||||
using Data.Data.Repositories;
|
||||
using Data.Managers;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Phase 18.5 Backfill-Opus scheduling contract
|
||||
/// (<see cref="UnifiedTrackService.BackfillOpusAsync"/> and <see cref="UnifiedTrackService.EnqueueOpusAsync"/>).
|
||||
/// These assert the enqueue decision — which tracks get a background derive scheduled — over a real
|
||||
/// <see cref="FileDb"/>, a real <see cref="TrackFormatResolver"/>, and an in-memory SQL store, with a
|
||||
/// recording <see cref="NoOpOpusTranscodeQueue"/> standing in for the background worker (the actual transcode
|
||||
/// is not exercised here — it needs ffmpeg and is out of scope for the scheduling contract).
|
||||
///
|
||||
/// The decision under test: a track is enqueued iff it lacks a COMPLETE Opus artifact (both the Opus audio
|
||||
/// bytes and the seek/setup sidecar). A track with both is skipped; a half-derived track (audio without
|
||||
/// sidecar) is treated as incomplete and re-enqueued so a backfill heals it.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class OpusBackfillTests
|
||||
{
|
||||
private string _testDir = string.Empty;
|
||||
private DeepDrftContext _context = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "OpusBackfillTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
|
||||
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new DeepDrftContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
_context.Dispose();
|
||||
try { Directory.Delete(_testDir, recursive: true); }
|
||||
catch { /* Best-effort cleanup — ignore failures */ }
|
||||
}
|
||||
|
||||
private TrackManager CreateManager()
|
||||
{
|
||||
var repository = new TrackRepository(
|
||||
_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
|
||||
return new TrackManager(
|
||||
repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
|
||||
}
|
||||
|
||||
private sealed record Harness(
|
||||
UnifiedTrackService Service,
|
||||
NoOpOpusTranscodeQueue Queue,
|
||||
TrackContentService Content,
|
||||
FileDb FileDatabase,
|
||||
ITrackService Sql);
|
||||
|
||||
private async Task<Harness> BuildAsync()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
Assert.That(fileDatabase, Is.Not.Null);
|
||||
|
||||
var content = new TrackContentService(
|
||||
fileDatabase!, new AudioProcessorRouter(
|
||||
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
|
||||
var waveforms = new WaveformProfileService(
|
||||
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
|
||||
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
|
||||
var resolver = new TrackFormatResolver(
|
||||
fileDatabase!, content, NullLogger<TrackFormatResolver>.Instance);
|
||||
var queue = new NoOpOpusTranscodeQueue();
|
||||
var sql = CreateManager();
|
||||
|
||||
var service = new UnifiedTrackService(
|
||||
content, sql, fileDatabase!, waveforms, queue, resolver,
|
||||
NullLogger<UnifiedTrackService>.Instance);
|
||||
|
||||
await fileDatabase!.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
|
||||
|
||||
return new Harness(service, queue, content, fileDatabase!, sql);
|
||||
}
|
||||
|
||||
// Seeds a track: stores a real source WAV in the tracks vault and a SQL row pointing at the same EntryKey.
|
||||
// Returns the EntryKey so the test can selectively add Opus artifacts to a subset.
|
||||
private async Task<string> SeedTrackAsync(Harness h, string title)
|
||||
{
|
||||
var wavPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
|
||||
await File.WriteAllBytesAsync(wavPath, BuildMinimalPcmWav(2.0));
|
||||
var unpersisted = await h.Content.AddTrackAsync(wavPath, title, "Artist");
|
||||
Assert.That(unpersisted, Is.Not.Null);
|
||||
|
||||
var dto = new TrackDto { EntryKey = unpersisted!.EntryKey, TrackName = title };
|
||||
var created = await h.Sql.Create(dto);
|
||||
Assert.That(created.Success, Is.True, created.Messages.FirstOrDefault()?.Message);
|
||||
|
||||
return unpersisted.EntryKey;
|
||||
}
|
||||
|
||||
private async Task StoreOpusAudioAsync(Harness h, string entryKey)
|
||||
{
|
||||
var opus = new AudioBinary(new AudioBinaryParams("opus"u8.ToArray(), 4, ".opus", 2.0, 320));
|
||||
Assert.That(
|
||||
await h.FileDatabase.RegisterResourceAsync(
|
||||
VaultConstants.TrackOpus, OpusTranscodeService.OpusAudioKey(entryKey), opus),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
private async Task StoreSidecarAsync(Harness h, string entryKey)
|
||||
{
|
||||
var sidecar = new MediaBinary(new MediaBinaryParams("idx"u8.ToArray(), 3, ".opusidx"));
|
||||
Assert.That(
|
||||
await h.FileDatabase.RegisterResourceAsync(
|
||||
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey), sidecar),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BackfillOpus_EnqueuesOnlyTracksWithoutCompleteOpus()
|
||||
{
|
||||
var h = await BuildAsync();
|
||||
|
||||
// Three tracks: one fully derived (audio + sidecar), one bare (no Opus), one half-derived (audio only).
|
||||
var complete = await SeedTrackAsync(h, "Complete");
|
||||
await StoreOpusAudioAsync(h, complete);
|
||||
await StoreSidecarAsync(h, complete);
|
||||
|
||||
var bare = await SeedTrackAsync(h, "Bare");
|
||||
|
||||
var halfDerived = await SeedTrackAsync(h, "HalfDerived");
|
||||
await StoreOpusAudioAsync(h, halfDerived); // audio but no sidecar → unseekable → treated as incomplete
|
||||
|
||||
var result = await h.Service.BackfillOpusAsync(CancellationToken.None);
|
||||
|
||||
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Value.Enqueued, Is.EqualTo(2), "the bare and half-derived tracks must be enqueued");
|
||||
Assert.That(result.Value.Skipped, Is.EqualTo(1), "the fully-derived track must be skipped");
|
||||
Assert.That(h.Queue.Enqueued, Does.Contain(bare));
|
||||
Assert.That(h.Queue.Enqueued, Does.Contain(halfDerived));
|
||||
Assert.That(h.Queue.Enqueued, Does.Not.Contain(complete), "a complete Opus artifact is not re-enqueued");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BackfillOpus_WhenAllTracksHaveOpus_EnqueuesNothing()
|
||||
{
|
||||
var h = await BuildAsync();
|
||||
|
||||
var a = await SeedTrackAsync(h, "A");
|
||||
await StoreOpusAudioAsync(h, a);
|
||||
await StoreSidecarAsync(h, a);
|
||||
|
||||
var result = await h.Service.BackfillOpusAsync(CancellationToken.None);
|
||||
|
||||
Assert.That(result.Success, Is.True);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Value.Enqueued, Is.Zero);
|
||||
Assert.That(result.Value.Skipped, Is.EqualTo(1));
|
||||
Assert.That(h.Queue.Enqueued, Is.Empty, "an all-derived catalogue schedules no transcodes");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EnqueueOpus_KnownTrack_Enqueues()
|
||||
{
|
||||
var h = await BuildAsync();
|
||||
var entryKey = await SeedTrackAsync(h, "Solo");
|
||||
|
||||
var result = await h.Service.EnqueueOpusAsync(entryKey, CancellationToken.None);
|
||||
|
||||
Assert.That(result.Success, Is.True);
|
||||
Assert.That(h.Queue.Enqueued, Does.Contain(entryKey));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EnqueueOpus_UnknownTrack_FailsWithNotFound_AndEnqueuesNothing()
|
||||
{
|
||||
var h = await BuildAsync();
|
||||
|
||||
var result = await h.Service.EnqueueOpusAsync("no-such-track", CancellationToken.None);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Success, Is.False);
|
||||
Assert.That(result.Messages.FirstOrDefault()?.Message, Is.EqualTo(UnifiedTrackService.TrackNotFoundMessage));
|
||||
Assert.That(h.Queue.Enqueued, Is.Empty, "an unknown track must not schedule a transcode");
|
||||
});
|
||||
}
|
||||
|
||||
// Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites.
|
||||
private static byte[] BuildMinimalPcmWav(double durationSeconds)
|
||||
{
|
||||
const int sampleRate = 44100;
|
||||
const ushort channels = 1;
|
||||
const ushort bitsPerSample = 16;
|
||||
const ushort blockAlign = channels * (bitsPerSample / 8);
|
||||
const uint byteRate = sampleRate * blockAlign;
|
||||
|
||||
var frames = (int)(sampleRate * durationSeconds);
|
||||
var data = new byte[frames * blockAlign];
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
|
||||
data[i * 2] = (byte)(sample & 0xFF);
|
||||
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("RIFF"));
|
||||
w.Write((uint)(36 + data.Length));
|
||||
w.Write(Encoding.ASCII.GetBytes("WAVE"));
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("fmt "));
|
||||
w.Write(16u);
|
||||
w.Write((ushort)1); // PCM
|
||||
w.Write(channels);
|
||||
w.Write((uint)sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write(bitsPerSample);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("data"));
|
||||
w.Write((uint)data.Length);
|
||||
w.Write(data);
|
||||
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Net;
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.JSInterop;
|
||||
using Microsoft.JSInterop.Infrastructure;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Phase 18.5 player-side format-selection seam
|
||||
/// (<see cref="StreamingAudioPlayerService.ResolveStreamFormatAsync"/>): the default policy (OQ2) of "Opus
|
||||
/// when the browser can decode Ogg Opus AND a sidecar exists, else lossless", the capability gate (AC7), and
|
||||
/// the sidecar-absent → lossless fallback (C2). The seam is the single, overridable hook 18.6 will use to
|
||||
/// inject the listener's quality preference; these tests pin the capability-gated default it falls through to.
|
||||
///
|
||||
/// The seam touches two collaborators: <see cref="AudioInteropService"/> (over a fake <see cref="IJSRuntime"/>
|
||||
/// — <c>canDecodeOggOpus</c> + <c>setOpusSidecar</c>) and <see cref="TrackMediaClient"/> (over a stub HTTP
|
||||
/// handler — the one-time sidecar fetch). Both are real instances wired over the fakes; only the network/JS
|
||||
/// boundary is faked, so the selection logic under test is exercised exactly as it runs in the browser.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class OpusFormatSelectionTests
|
||||
{
|
||||
// A scriptable JS runtime: canDecodeOggOpus returns a configured bool; setOpusSidecar returns a
|
||||
// configured success/failure; every other invocation returns default. Records the calls so a test can
|
||||
// assert the set-before-init contract was honoured (the sidecar was actually handed to the player).
|
||||
private sealed class FakeJsRuntime : IJSRuntime
|
||||
{
|
||||
private readonly bool _canDecode;
|
||||
private readonly bool _sidecarParseSucceeds;
|
||||
|
||||
public FakeJsRuntime(bool canDecode, bool sidecarParseSucceeds)
|
||||
{
|
||||
_canDecode = canDecode;
|
||||
_sidecarParseSucceeds = sidecarParseSucceeds;
|
||||
}
|
||||
|
||||
public int SetSidecarCallCount { get; private set; }
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
{
|
||||
if (identifier == "DeepDrftAudio.canDecodeOggOpus")
|
||||
return ValueTask.FromResult((TValue)(object)_canDecode);
|
||||
|
||||
if (identifier == "DeepDrftAudio.setOpusSidecar")
|
||||
{
|
||||
SetSidecarCallCount++;
|
||||
var result = new AudioOperationResult
|
||||
{
|
||||
Success = _sidecarParseSucceeds,
|
||||
Error = _sidecarParseSucceeds ? null : "Invalid Opus sidecar blob",
|
||||
};
|
||||
return ValueTask.FromResult((TValue)(object)result);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<TValue>(default!);
|
||||
}
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||
=> InvokeAsync<TValue>(identifier, args);
|
||||
}
|
||||
|
||||
// Returns a configured status (with a body) for GET api/track/{id}/opus/seekdata; any other request 404s.
|
||||
private sealed class StubSidecarHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly byte[] _body;
|
||||
|
||||
public StubSidecarHandler(HttpStatusCode status, byte[]? body = null)
|
||||
{
|
||||
_status = status;
|
||||
_body = body ?? [];
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = new HttpResponseMessage(_status);
|
||||
if (_status == HttpStatusCode.OK)
|
||||
response.Content = new ByteArrayContent(_body);
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public SingleClientFactory(HttpMessageHandler handler) => _handler = handler;
|
||||
|
||||
public HttpClient CreateClient(string name) =>
|
||||
new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://content.test/") };
|
||||
}
|
||||
|
||||
// Exposes the protected seam for direct assertion. The 18.6 override will replace this same method.
|
||||
private sealed class TestablePlayer : StreamingAudioPlayerService
|
||||
{
|
||||
public TestablePlayer(AudioInteropService interop, TrackMediaClient media)
|
||||
: base(interop, media, NullLogger<StreamingAudioPlayerService>.Instance) { }
|
||||
|
||||
public Task<AudioFormat> ResolveFormatForTest(string entryKey) =>
|
||||
ResolveStreamFormatAsync(entryKey, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static TestablePlayer BuildPlayer(
|
||||
bool canDecode, bool sidecarParseSucceeds, HttpStatusCode sidecarStatus, byte[]? sidecarBody)
|
||||
{
|
||||
var js = new FakeJsRuntime(canDecode, sidecarParseSucceeds);
|
||||
var interop = new AudioInteropService(js);
|
||||
var media = new TrackMediaClient(new SingleClientFactory(new StubSidecarHandler(sidecarStatus, sidecarBody)));
|
||||
return new TestablePlayer(interop, media);
|
||||
}
|
||||
|
||||
private static readonly byte[] SidecarBytes = "setup-header+seek-index"u8.ToArray();
|
||||
|
||||
// Capable browser + present sidecar → Opus. The happy path: the default policy picks the low-data format.
|
||||
[Test]
|
||||
public async Task ResolveStreamFormat_CapableBrowser_SidecarPresent_ChoosesOpus()
|
||||
{
|
||||
var player = BuildPlayer(canDecode: true, sidecarParseSucceeds: true,
|
||||
HttpStatusCode.OK, SidecarBytes);
|
||||
|
||||
var format = await player.ResolveFormatForTest("track-1");
|
||||
|
||||
Assert.That(format, Is.EqualTo(AudioFormat.Opus));
|
||||
}
|
||||
|
||||
// Capability gate (AC7): a browser that cannot decode Ogg Opus always gets lossless, and the sidecar is
|
||||
// never even fetched/injected — Opus is off the table before any sidecar work.
|
||||
[Test]
|
||||
public async Task ResolveStreamFormat_IncapableBrowser_ChoosesLossless_AndDoesNotInjectSidecar()
|
||||
{
|
||||
var js = new FakeJsRuntime(canDecode: false, sidecarParseSucceeds: true);
|
||||
var interop = new AudioInteropService(js);
|
||||
var media = new TrackMediaClient(new SingleClientFactory(
|
||||
new StubSidecarHandler(HttpStatusCode.OK, SidecarBytes)));
|
||||
var player = new TestablePlayer(interop, media);
|
||||
|
||||
var format = await player.ResolveFormatForTest("track-1");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "incapable browser must fall back to lossless");
|
||||
Assert.That(js.SetSidecarCallCount, Is.Zero, "no sidecar should be injected when Opus is gated out");
|
||||
});
|
||||
}
|
||||
|
||||
// C2 fallback: capable browser but no sidecar (legacy / not-yet-transcoded track, 404) → lossless.
|
||||
[Test]
|
||||
public async Task ResolveStreamFormat_CapableBrowser_NoSidecar_FallsBackToLossless()
|
||||
{
|
||||
var player = BuildPlayer(canDecode: true, sidecarParseSucceeds: true,
|
||||
HttpStatusCode.NotFound, sidecarBody: null);
|
||||
|
||||
var format = await player.ResolveFormatForTest("track-1");
|
||||
|
||||
Assert.That(format, Is.EqualTo(AudioFormat.Lossless),
|
||||
"a capable browser with no Opus sidecar must request lossless, not Opus");
|
||||
}
|
||||
|
||||
// A present-but-unparseable sidecar (the JS decoder rejects the bytes) → lossless, so a malformed sidecar
|
||||
// never breaks playback. The injection was attempted (set-before-init), but its failure degrades safely.
|
||||
[Test]
|
||||
public async Task ResolveStreamFormat_SidecarPresentButUnparseable_FallsBackToLossless()
|
||||
{
|
||||
var js = new FakeJsRuntime(canDecode: true, sidecarParseSucceeds: false);
|
||||
var interop = new AudioInteropService(js);
|
||||
var media = new TrackMediaClient(new SingleClientFactory(
|
||||
new StubSidecarHandler(HttpStatusCode.OK, SidecarBytes)));
|
||||
var player = new TestablePlayer(interop, media);
|
||||
|
||||
var format = await player.ResolveFormatForTest("track-1");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(format, Is.EqualTo(AudioFormat.Lossless), "an unparseable sidecar must degrade to lossless");
|
||||
Assert.That(js.SetSidecarCallCount, Is.EqualTo(1), "the player attempted the set-before-init injection");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Text;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Confirms the Phase 18.5 acceptance point that <see cref="UnifiedTrackService.ReplaceAudioAsync"/>
|
||||
/// regenerates the Opus artifact: a replace must schedule a background Opus re-derive for the track, because
|
||||
/// the stale Opus no longer matches the new source bytes (the same reason it regenerates the waveform datums
|
||||
/// and re-derives duration). The enqueue was wired in 18.1; this test pins it so a future refactor cannot
|
||||
/// silently drop it, leaving a track serving Opus that does not match its lossless source.
|
||||
///
|
||||
/// The vault + content + waveform collaborators are real (over a temp-dir <see cref="FileDb"/>); the SQL
|
||||
/// service is a focused fake. The fake is used here rather than the in-memory EF store because the replace
|
||||
/// path's duration write goes through <c>SetDuration</c> → <c>ExecuteUpdateAsync</c>, which the EF in-memory
|
||||
/// provider does not support — the fake lets the orchestration run to the post-write enqueue under test.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReplaceAudioOpusRegenTests
|
||||
{
|
||||
private string _testDir = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "ReplaceAudioOpusRegenTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
try { Directory.Delete(_testDir, recursive: true); }
|
||||
catch { /* Best-effort cleanup — ignore failures */ }
|
||||
}
|
||||
|
||||
// A focused ITrackService fake: only GetById (returns the seeded track) and SetDuration (records the
|
||||
// write and reports success) are meaningful — the two members the replace path calls. Everything else
|
||||
// throws, documenting that the replace orchestration touches nothing else on the SQL boundary.
|
||||
private sealed class FakeTrackService : ITrackService
|
||||
{
|
||||
private readonly TrackDto _track;
|
||||
public double? LastDurationWritten { get; private set; }
|
||||
|
||||
public FakeTrackService(TrackDto track) => _track = track;
|
||||
|
||||
public Task<ResultContainer<TrackDto?>> GetById(long id) =>
|
||||
Task.FromResult(ResultContainer<TrackDto?>.CreatePassResult(id == _track.Id ? _track : null));
|
||||
|
||||
public Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken ct = default)
|
||||
{
|
||||
LastDurationWritten = durationSeconds;
|
||||
return Task.FromResult(ResultContainer<int>.CreatePassResult(1));
|
||||
}
|
||||
|
||||
// Unused by the replace path — fail loudly if the orchestration ever reaches them.
|
||||
public Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<List<TrackDto>>> GetAll() => throw new NotSupportedException();
|
||||
public Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int p, int s, string? c, bool d, TrackFilter? f = null, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<int>> UpdateDuration(long id, double d, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(string t, string a, ReleaseDto r, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(string t, string a, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<TrackDto>> Create(TrackDto t) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<TrackDto>> Update(TrackDto t) => throw new NotSupportedException();
|
||||
public Task<Result> Delete(long id) => throw new NotSupportedException();
|
||||
public Task<Result> DeleteRelease(long id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAudio_EnqueuesOpusRegen_ForTheReplacedTrack()
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
Assert.That(fileDatabase, Is.Not.Null);
|
||||
|
||||
var content = new TrackContentService(
|
||||
fileDatabase!, new AudioProcessorRouter(
|
||||
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
|
||||
var waveforms = new WaveformProfileService(
|
||||
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
|
||||
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
|
||||
var resolver = new TrackFormatResolver(
|
||||
fileDatabase!, content, NullLogger<TrackFormatResolver>.Instance);
|
||||
var queue = new NoOpOpusTranscodeQueue();
|
||||
|
||||
// Seed the source WAV in the vault and point a fake SQL row at the same EntryKey.
|
||||
var originalPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
|
||||
await File.WriteAllBytesAsync(originalPath, BuildMinimalPcmWav(2.0));
|
||||
var unpersisted = await content.AddTrackAsync(originalPath, "Original", "Artist");
|
||||
Assert.That(unpersisted, Is.Not.Null);
|
||||
|
||||
const long trackId = 42;
|
||||
var sql = new FakeTrackService(new TrackDto { Id = trackId, EntryKey = unpersisted!.EntryKey, TrackName = "Original" });
|
||||
|
||||
var service = new UnifiedTrackService(
|
||||
content, sql, fileDatabase!, waveforms, queue, resolver,
|
||||
NullLogger<UnifiedTrackService>.Instance);
|
||||
|
||||
// Replace the audio with a longer take.
|
||||
var replacementPath = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
|
||||
await File.WriteAllBytesAsync(replacementPath, BuildMinimalPcmWav(6.0));
|
||||
|
||||
var result = await service.ReplaceAudioAsync(trackId, replacementPath, CancellationToken.None);
|
||||
|
||||
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(queue.Enqueued, Does.Contain(unpersisted.EntryKey),
|
||||
"a replace must schedule an Opus re-derive so the artifact tracks the new source");
|
||||
Assert.That(sql.LastDurationWritten, Is.GreaterThan(0),
|
||||
"the replace must also write the new duration (the enqueue follows a successful duration write)");
|
||||
});
|
||||
}
|
||||
|
||||
// Standard-PCM mono 16-bit 44.1 kHz WAV, full-scale square wave. Same layout as the other suites.
|
||||
private static byte[] BuildMinimalPcmWav(double durationSeconds)
|
||||
{
|
||||
const int sampleRate = 44100;
|
||||
const ushort channels = 1;
|
||||
const ushort bitsPerSample = 16;
|
||||
const ushort blockAlign = channels * (bitsPerSample / 8);
|
||||
const uint byteRate = sampleRate * blockAlign;
|
||||
|
||||
var frames = (int)(sampleRate * durationSeconds);
|
||||
var data = new byte[frames * blockAlign];
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
|
||||
data[i * 2] = (byte)(sample & 0xFF);
|
||||
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("RIFF"));
|
||||
w.Write((uint)(36 + data.Length));
|
||||
w.Write(Encoding.ASCII.GetBytes("WAVE"));
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("fmt "));
|
||||
w.Write(16u);
|
||||
w.Write((ushort)1); // PCM
|
||||
w.Write(channels);
|
||||
w.Write((uint)sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write(bitsPerSample);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("data"));
|
||||
w.Write((uint)data.Length);
|
||||
w.Write(data);
|
||||
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Data.Managers;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
@@ -74,10 +75,13 @@ public class UploadDuplicateDetectionTests
|
||||
var waveforms = new WaveformProfileService(
|
||||
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
|
||||
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
|
||||
var resolver = new TrackFormatResolver(
|
||||
fileDatabase!, content, NullLogger<TrackFormatResolver>.Instance);
|
||||
|
||||
return new UnifiedTrackService(
|
||||
content, sqlTrackService, fileDatabase!, waveforms,
|
||||
new NoOpOpusTranscodeQueue(),
|
||||
resolver,
|
||||
NullLogger<UnifiedTrackService>.Instance);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user