2bde4908d7
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.
252 lines
9.8 KiB
C#
252 lines
9.8 KiB
C#
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();
|
|
}
|
|
}
|