Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization)
This commit is contained in:
@@ -5,6 +5,7 @@ using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
@@ -20,6 +21,7 @@ public class TrackController : ControllerBase
|
||||
private readonly UnifiedTrackService _unifiedService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly TrackFormatResolver _formatResolver;
|
||||
private readonly UploadStagingDirectory _stagingDirectory;
|
||||
private readonly ILogger<TrackController> _logger;
|
||||
|
||||
@@ -35,6 +37,7 @@ public class TrackController : ControllerBase
|
||||
UnifiedTrackService unifiedService,
|
||||
ITrackService sqlTrackService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
TrackFormatResolver formatResolver,
|
||||
UploadStagingDirectory stagingDirectory,
|
||||
ILogger<TrackController> logger)
|
||||
{
|
||||
@@ -43,6 +46,7 @@ public class TrackController : ControllerBase
|
||||
_unifiedService = unifiedService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_formatResolver = formatResolver;
|
||||
_stagingDirectory = stagingDirectory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -245,6 +249,40 @@ public class TrackController : ControllerBase
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
// GET api/track/opus-status ([ApiKeyAuthorize])
|
||||
// Admin Post-Processing view (18.6): returns every track with a flag for whether it carries a COMPLETE
|
||||
// Opus artifact — both the Opus audio AND the seek/setup sidecar present (TrackFormatResolver.HasOpusAsync,
|
||||
// the same completeness rule the 18.5 Backfill-Opus pass enqueues against; a half-derived track counts as
|
||||
// missing). Mirrors GET waveform-status exactly: same ApiKey auth, same unpaged whole-catalogue shape, same
|
||||
// literal-route placement before "{trackId}". The CMS reads it to show the Backfill-Opus "missing N" badge
|
||||
// and to poll per-track Post-Processing status after an upload.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpGet("opus-status")]
|
||||
public async Task<ActionResult> GetOpusStatus()
|
||||
{
|
||||
var tracks = await _sqlTrackService.GetAll();
|
||||
if (!tracks.Success || tracks.Value is null)
|
||||
{
|
||||
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetOpusStatus failed to load tracks: {Error}", error);
|
||||
return StatusCode(500, "Failed to load tracks");
|
||||
}
|
||||
|
||||
var status = new List<OpusStatusDto>(tracks.Value.Count);
|
||||
foreach (var track in tracks.Value)
|
||||
{
|
||||
status.Add(new OpusStatusDto
|
||||
{
|
||||
TrackId = track.Id,
|
||||
EntryKey = track.EntryKey,
|
||||
TrackName = track.TrackName,
|
||||
HasOpus = await _formatResolver.HasOpusAsync(track.EntryKey),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
// POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
|
||||
// One-time admin backfill: for every track whose SQL duration is still null, read the duration from
|
||||
// the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
|
||||
@@ -265,6 +303,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.
|
||||
@@ -642,10 +701,27 @@ public class TrackController : ControllerBase
|
||||
|
||||
// --- Parameterized routes ---
|
||||
|
||||
// GET api/track/{trackId}?format=opus|lossless (unauthenticated)
|
||||
// Streams the track's audio bytes with HTTP Range support. The optional `format` selector (Phase 18.3)
|
||||
// picks the delivery rendering: absent or unrecognized ⇒ Lossless (byte-identical to pre-Phase-18 —
|
||||
// the existing zero-copy disk-stream path, untouched); `opus` ⇒ the derived Ogg Opus 320 artifact
|
||||
// when present, falling back to lossless when it is not (C2 — never 404/silence). The Opus path serves
|
||||
// the resolved in-memory bytes via File(..., enableRangeProcessing: true) so Range: bytes=X- still
|
||||
// yields 206 (load-bearing for streaming + seek), matching the lossless disk-stream's range behavior.
|
||||
[HttpGet("{trackId}")]
|
||||
public async Task<ActionResult> GetTrack(string trackId)
|
||||
public async Task<ActionResult> GetTrack(string trackId, [FromQuery] string? format = null)
|
||||
{
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}, format: {Format}", trackId, format);
|
||||
|
||||
// Only `opus` diverges from today's behavior; everything else (null, "lossless", garbage) takes the
|
||||
// unchanged lossless disk-stream path below, preserving the large-file zero-copy streaming. Routing
|
||||
// lossless through the resolver would force the whole source (up to ~1 GB) into memory per request —
|
||||
// a regression the resolver's in-memory byte[] result is fine for Opus (small) but not for lossless.
|
||||
if (Enum.TryParse<AudioFormat>(format, ignoreCase: true, out var requestedFormat)
|
||||
&& requestedFormat == AudioFormat.Opus)
|
||||
{
|
||||
return await GetTrackOpusAsync(trackId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -700,6 +776,58 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// The ?format=opus arm of GetTrack. Resolves the Opus artifact (or the lossless fallback when none
|
||||
// exists, C2) via TrackFormatResolver and serves the resolved bytes with explicit range processing.
|
||||
// enableRangeProcessing:true is the load-bearing detail the 18.2 reviewer flagged: File(byte[], ...)
|
||||
// does NOT get ASP.NET's automatic range handling unless asked, so without this flag a Range: bytes=X-
|
||||
// would silently return the whole body as 200 instead of a 206 slice — breaking seek for the Opus path
|
||||
// (and Phase 21 windowing). The resolver reports the *actually-served* format via ResolvedAudio, so the
|
||||
// content-type matches the bytes (audio/ogg on a hit, the source MIME on a fallback) and the eventual
|
||||
// client decoder dispatches correctly.
|
||||
private async Task<ActionResult> GetTrackOpusAsync(string trackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolved = await _formatResolver.ResolveAsync(trackId, AudioFormat.Opus);
|
||||
if (resolved is null)
|
||||
{
|
||||
_logger.LogWarning("Track not found for Opus request: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Streaming track {TrackId} as {Format} ({Size} bytes, {ContentType})",
|
||||
trackId, resolved.ResolvedFormat, resolved.Audio.Buffer.Length, resolved.ContentType);
|
||||
|
||||
return File(resolved.Audio.Buffer, resolved.ContentType, enableRangeProcessing: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving track as Opus: {TrackId}", trackId);
|
||||
return StatusCode(500, "Internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
// GET api/track/{trackId}/opus/seekdata (unauthenticated)
|
||||
// Returns the Opus setup-header + granule→byte seek-index sidecar bytes (Phase 18.3). The client
|
||||
// fetches this once on track load and parses it into OpusSeekData (18.4) before issuing any Opus seek.
|
||||
// Raw octet-stream — the bytes are the OpusSidecar blob exactly as 18.1 stored them. 404 when no sidecar
|
||||
// is stored (no Opus artifact yet, or an older derive predating the sidecar); the client then degrades
|
||||
// to lossless, mirroring the C2 posture of the audio path. Same public auth posture as the audio stream.
|
||||
// The "opus/seekdata" literal suffix keeps this distinct from the audio and waveform routes.
|
||||
[HttpGet("{trackId}/opus/seekdata")]
|
||||
public async Task<ActionResult> GetOpusSeekData(string trackId)
|
||||
{
|
||||
var sidecar = await _formatResolver.GetOpusSidecarAsync(trackId);
|
||||
if (sidecar is null)
|
||||
{
|
||||
_logger.LogInformation("No Opus sidecar for track: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return File(sidecar, "application/octet-stream");
|
||||
}
|
||||
|
||||
// GET api/track/{trackId}/waveform (unauthenticated)
|
||||
// Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
|
||||
// data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
|
||||
@@ -816,6 +944,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)
|
||||
|
||||
@@ -4,6 +4,7 @@ using DeepDrftAPI;
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftAPI.Services.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
@@ -66,6 +67,13 @@ builder.Services
|
||||
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
builder.Services.AddScoped<UnifiedTrackService>();
|
||||
|
||||
// Background Opus transcode (Phase 18.1, OQ6). One singleton is both the enqueue seam
|
||||
// (IOpusTranscodeQueue, injected into the scoped UnifiedTrackService) and the hosted drain loop
|
||||
// (IHostedService). It resolves OpusTranscodeService — a singleton — so no scope is captured.
|
||||
builder.Services.AddSingleton<OpusTranscodeBackgroundService>();
|
||||
builder.Services.AddSingleton<IOpusTranscodeQueue>(sp => sp.GetRequiredService<OpusTranscodeBackgroundService>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<OpusTranscodeBackgroundService>());
|
||||
|
||||
// Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL).
|
||||
// EventManager is the IEventService boundary; EventRepository owns the EF writes and the
|
||||
// release-resolution + counter-bump transaction.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace DeepDrftAPI.Services.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// The enqueue seam for the background Opus transcode (OQ6 / §3.1a). <see cref="UnifiedTrackService"/>
|
||||
/// depends only on this thin interface — not on the worker — so adding the background derive to the
|
||||
/// upload/replace paths costs one small dependency, not the whole transcode graph. Enqueuing is
|
||||
/// non-blocking and best-effort: a freshly uploaded track is already persisted and playable losslessly
|
||||
/// before anything is enqueued, and the transcode runs off the request thread.
|
||||
/// </summary>
|
||||
public interface IOpusTranscodeQueue
|
||||
{
|
||||
/// <summary>
|
||||
/// Schedules a background Opus derive for the track identified by <paramref name="entryKey"/>. Returns
|
||||
/// immediately. A dropped or failed enqueue must not affect the caller — the track remains
|
||||
/// lossless-only and eligible for backfill.
|
||||
/// </summary>
|
||||
void Enqueue(string entryKey);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Threading.Channels;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
|
||||
namespace DeepDrftAPI.Services.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// The background worker behind <see cref="IOpusTranscodeQueue"/> (OQ6 / §3.1a). An unbounded in-process
|
||||
/// channel buffers EntryKeys enqueued by the upload and replace-audio paths; a single hosted loop drains
|
||||
/// them one at a time and runs <see cref="OpusTranscodeService.TranscodeAndStoreAsync"/> for each. Serial
|
||||
/// by design — a transcode is CPU-heavy (§3.1), so running them concurrently would starve request
|
||||
/// handling; one-at-a-time keeps the derive strictly off the hot path without saturating the host.
|
||||
///
|
||||
/// This worker IS the queue (implements <see cref="IOpusTranscodeQueue"/>) so enqueue and drain share one
|
||||
/// channel with no extra indirection. It is registered as a singleton and surfaced under both the
|
||||
/// interface and <see cref="IHostedService"/>.
|
||||
/// </summary>
|
||||
public sealed class OpusTranscodeBackgroundService : BackgroundService, IOpusTranscodeQueue
|
||||
{
|
||||
private readonly Channel<string> _channel =
|
||||
Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
private readonly OpusTranscodeService _transcodeService;
|
||||
private readonly ILogger<OpusTranscodeBackgroundService> _logger;
|
||||
|
||||
public OpusTranscodeBackgroundService(
|
||||
OpusTranscodeService transcodeService,
|
||||
ILogger<OpusTranscodeBackgroundService> logger)
|
||||
{
|
||||
_transcodeService = transcodeService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void Enqueue(string entryKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryKey))
|
||||
return;
|
||||
|
||||
if (!_channel.Writer.TryWrite(entryKey))
|
||||
{
|
||||
// Unbounded writer only rejects after Complete(), i.e. during shutdown. The track stays
|
||||
// lossless-only and is eligible for backfill, so a dropped enqueue is non-fatal — log it.
|
||||
_logger.LogWarning("Opus transcode: could not enqueue {EntryKey} (queue closed).", entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var entryKey in _channel.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _transcodeService.TranscodeAndStoreAsync(entryKey, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break; // host shutting down
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// TranscodeAndStoreAsync already swallows expected failures; this guards the loop against
|
||||
// anything unexpected so one bad track never kills the worker.
|
||||
_logger.LogError(ex, "Opus transcode: unhandled failure draining {EntryKey}; worker continues.", entryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_channel.Writer.TryComplete();
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using DeepDrftAPI.Services.Opus;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
@@ -39,6 +41,8 @@ public class UnifiedTrackService
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly IOpusTranscodeQueue _opusTranscodeQueue;
|
||||
private readonly TrackFormatResolver _formatResolver;
|
||||
private readonly ILogger<UnifiedTrackService> _logger;
|
||||
|
||||
public UnifiedTrackService(
|
||||
@@ -46,12 +50,16 @@ public class UnifiedTrackService
|
||||
ITrackService sqlTrackService,
|
||||
FileDb fileDatabase,
|
||||
WaveformProfileService waveformProfileService,
|
||||
IOpusTranscodeQueue opusTranscodeQueue,
|
||||
TrackFormatResolver formatResolver,
|
||||
ILogger<UnifiedTrackService> logger)
|
||||
{
|
||||
_contentTrackContentService = contentTrackContentService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_opusTranscodeQueue = opusTranscodeQueue;
|
||||
_formatResolver = formatResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -219,6 +227,11 @@ public class UnifiedTrackService
|
||||
// frontend, so a failure here is logged and swallowed — never fails the upload.
|
||||
await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);
|
||||
|
||||
// Schedule the low-data Opus derive (OQ6 / §3.1a): the track is persisted and lossless-playable
|
||||
// NOW; the transcode + seek-index build run on a background worker. Non-blocking and best-effort
|
||||
// — the upload response never waits on it, and a transcode failure leaves the track lossless-only.
|
||||
_opusTranscodeQueue.Enqueue(unpersisted.EntryKey);
|
||||
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
@@ -297,6 +310,11 @@ public class UnifiedTrackService
|
||||
return Result.CreateFailResult("Audio replaced but duration metadata could not be updated.");
|
||||
}
|
||||
|
||||
// The stale Opus artifact (if any) no longer matches the new source. Schedule a background
|
||||
// regenerate — the transcode service overwrites the prior artifacts in place keyed by the same
|
||||
// EntryKey. Best-effort, off the request thread, mirrors the waveform regen above.
|
||||
_opusTranscodeQueue.Enqueue(entryKey);
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
@@ -379,6 +397,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
|
||||
|
||||
@@ -4,6 +4,7 @@ using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftContent.Processors.Opus;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetBlocks.Utilities.Environment;
|
||||
|
||||
@@ -44,6 +45,7 @@ namespace DeepDrftAPI
|
||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||
InitializeImageVault(db).GetAwaiter().GetResult();
|
||||
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
|
||||
InitializeTrackOpusVault(db).GetAwaiter().GetResult();
|
||||
return db;
|
||||
});
|
||||
|
||||
@@ -65,6 +67,21 @@ namespace DeepDrftAPI
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
|
||||
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
|
||||
|
||||
// Opus low-data transcode (Phase 18.1). The domain service lives in DeepDrftContent; the host
|
||||
// owns only the engine config and the background worker. Bitrate/ffmpeg-path come from the
|
||||
// OpusTranscode config section; StagingPath is forced to the same data-disk staging directory
|
||||
// the upload path uses so large transcode temp files never land on the /tmp tmpfs.
|
||||
builder.Services.Configure<OpusTranscodeOptions>(
|
||||
builder.Configuration.GetSection(nameof(OpusTranscodeOptions)));
|
||||
builder.Services.PostConfigure<OpusTranscodeOptions>(o => o.StagingPath = stagingPath);
|
||||
builder.Services.AddSingleton<FfmpegOpusEncoder>();
|
||||
builder.Services.AddSingleton<OpusTranscodeService>();
|
||||
|
||||
// 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<TrackFormatResolver>();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -107,5 +124,15 @@ namespace DeepDrftAPI
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the track-opus vault exists (Phase 18.1). Holds the derived low-data Ogg Opus artifacts
|
||||
// — the Opus audio bytes and the setup-header + seek-index sidecar — keyed by the track's EntryKey.
|
||||
private static async Task InitializeTrackOpusVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.TrackOpus))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"DeepDrftContent.Controllers.TrackController": "Information"
|
||||
"Default": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
Reference in New Issue
Block a user