Phase 9 Wave 2: api/release endpoint family — medium-aware reads + metadata writes
Adds ReleaseRepository/ReleaseManager (IReleaseService) for paged medium-filtered release reads and Session/Mix satellite writes, UnifiedReleaseService orchestrating vault+SQL, and ReleaseController (5 endpoints). Refactors WaveformProfileService for configurable bucketCount/vaultName (backward-compatible) and adds the mix-waveforms vault. Promotes brittle error-string literals to named constants (MixHasNoTrackMessage, MixTrackNoAudioMessage) on UnifiedReleaseService.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ReleaseController : ControllerBase
|
||||
{
|
||||
private readonly IReleaseService _releaseService;
|
||||
private readonly UnifiedReleaseService _unifiedReleaseService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly ILogger<ReleaseController> _logger;
|
||||
|
||||
public ReleaseController(
|
||||
IReleaseService releaseService,
|
||||
UnifiedReleaseService unifiedReleaseService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
ILogger<ReleaseController> logger)
|
||||
{
|
||||
_releaseService = releaseService;
|
||||
_unifiedReleaseService = unifiedReleaseService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// GET api/release?medium=session&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth)
|
||||
// Paged release list, optionally filtered to one medium. The matching medium's metadata satellite is
|
||||
// populated; the others are null. Public browse data, same auth posture as GET api/track/page.
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> GetReleases(
|
||||
[FromQuery] string? medium = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? sortColumn = null,
|
||||
[FromQuery] bool sortDescending = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ReleaseMedium? parsedMedium = null;
|
||||
if (!string.IsNullOrWhiteSpace(medium))
|
||||
{
|
||||
if (!Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var m) || !Enum.IsDefined(m))
|
||||
return BadRequest($"Unrecognised medium: {medium}");
|
||||
parsedMedium = m;
|
||||
}
|
||||
|
||||
var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, ct);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetReleases failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load releases");
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/release/{id}/mix/waveform (unauthenticated)
|
||||
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
|
||||
// but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key,
|
||||
// or no datum is stored. Declared before the shorter "{id:long}" route for clarity.
|
||||
[HttpGet("{id:long}/mix/waveform")]
|
||||
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default)
|
||||
{
|
||||
var lookup = await _releaseService.GetByIdAsync(id, ct);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetMixWaveform lookup failed for {ReleaseId}: {Error}", id, error);
|
||||
return StatusCode(500, "Failed to load release");
|
||||
}
|
||||
|
||||
var release = lookup.Value;
|
||||
var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey;
|
||||
if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey))
|
||||
{
|
||||
_logger.LogInformation("No mix waveform datum for release: {ReleaseId}", id);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("Mix waveform key set but no datum stored for release: {ReleaseId}", id);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(new WaveformProfileDto
|
||||
{
|
||||
BucketCount = bytes.Length,
|
||||
Data = Convert.ToBase64String(bytes),
|
||||
});
|
||||
}
|
||||
|
||||
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
|
||||
// Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform,
|
||||
// store it in the mix-waveforms vault, and set MixMetadata.WaveformEntryKey. 404 when the release is
|
||||
// missing or has no stored audio; 500 on compute/storage failure. Declared before "{id:long}".
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{id:long}/mix/waveform")]
|
||||
public async Task<ActionResult> GenerateMixWaveform(long id, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _unifiedReleaseService.TriggerMixWaveformAsync(id, ct);
|
||||
if (result.Success)
|
||||
return Ok();
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal)
|
||||
|| string.Equals(error, UnifiedReleaseService.MixTrackNoAudioMessage, StringComparison.Ordinal)
|
||||
|| string.Equals(error, UnifiedReleaseService.MixHasNoTrackMessage, StringComparison.Ordinal))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_logger.LogError("GenerateMixWaveform failed for {ReleaseId}: {Error}", id, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// POST api/release/{id}/session/hero-image ([ApiKeyAuthorize], multipart)
|
||||
// Stores a hero image in the images vault and sets SessionMetadata.HeroImageEntryKey. The release
|
||||
// must be a Session medium (enforced in the service). Declared before "{id:long}".
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{id:long}/session/hero-image")]
|
||||
[RequestSizeLimit(50_000_000)]
|
||||
public async Task<ActionResult> UploadSessionHeroImage(
|
||||
long id,
|
||||
[FromForm] IFormFile? image,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (image is null || image.Length == 0)
|
||||
return BadRequest("Image file is required");
|
||||
|
||||
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
|
||||
{
|
||||
_logger.LogWarning("UploadSessionHeroImage rejected: unsupported content type '{ContentType}'", image.ContentType);
|
||||
return BadRequest($"Unsupported image content type: {image.ContentType}");
|
||||
}
|
||||
|
||||
var result = await _unifiedReleaseService.SetHeroImageAsync(id, image, ct);
|
||||
if (result.Success)
|
||||
return Ok();
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
|
||||
_logger.LogError("UploadSessionHeroImage failed for {ReleaseId}: {Error}", id, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// GET api/release/{id} (unauthenticated)
|
||||
// Single release with both metadata navs (nulls for non-matching media). Declared after the longer
|
||||
// "{id:long}/mix/waveform" routes so the segmented routes resolve first.
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _releaseService.GetByIdAsync(id, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetReleaseById failed for {ReleaseId}: {Error}", id, error);
|
||||
return StatusCode(500, "Failed to load release");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,14 @@ builder.Services
|
||||
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
builder.Services.AddScoped<UnifiedTrackService>();
|
||||
|
||||
// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
|
||||
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
|
||||
builder.Services
|
||||
.AddScoped<ReleaseRepository>()
|
||||
.AddScoped<ReleaseManager>()
|
||||
.AddScoped<IReleaseService>(sp => sp.GetRequiredService<ReleaseManager>());
|
||||
builder.Services.AddScoped<UnifiedReleaseService>();
|
||||
|
||||
// AuthBlocks: JWT Bearer auth, Identity, EF schema, role + admin seeding. This API host owns the
|
||||
// AuthBlocks API surface (registration, migration/seed, endpoint mounting). The Manager keeps only
|
||||
// web-side auth (AuthBlocksWeb) and never holds the signing secret, email creds, or admin creds.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftAPI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Host-internal orchestrator for the two release metadata write paths. Mirrors
|
||||
/// <see cref="UnifiedTrackService"/>: it makes DeepDrftAPI the single authority over both the vault
|
||||
/// (FileDatabase) and SQL satellite rows, so the controller stays a thin HTTP boundary and no caller
|
||||
/// coordinates the two stores.
|
||||
/// </summary>
|
||||
public class UnifiedReleaseService
|
||||
{
|
||||
// High-res bucket count for Mix waveforms — 4x the player-bar default (512), feeding the
|
||||
// public-site MixWaveformVisualizer.
|
||||
private const int MixWaveformBucketCount = 2048;
|
||||
|
||||
/// <summary>Error message returned when the Mix release has no linked track.</summary>
|
||||
public const string MixHasNoTrackMessage = "Mix release has no track.";
|
||||
|
||||
/// <summary>Error message returned when the Mix track has no audio stored in the vault.</summary>
|
||||
public const string MixTrackNoAudioMessage = "No audio stored for the Mix track.";
|
||||
|
||||
private readonly IReleaseService _releaseService;
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly ImageProcessor _imageProcessor;
|
||||
private readonly TrackContentService _trackContentService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly ILogger<UnifiedReleaseService> _logger;
|
||||
|
||||
public UnifiedReleaseService(
|
||||
IReleaseService releaseService,
|
||||
FileDb fileDatabase,
|
||||
ImageProcessor imageProcessor,
|
||||
TrackContentService trackContentService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
ILogger<UnifiedReleaseService> logger)
|
||||
{
|
||||
_releaseService = releaseService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_imageProcessor = imageProcessor;
|
||||
_trackContentService = trackContentService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a hero image into the Images vault, then point the release's Session satellite at it.
|
||||
/// The medium check lives in <see cref="IReleaseService.SetSessionHeroImageAsync"/>: if the release
|
||||
/// is not a Session, the satellite is not written and the image is orphaned (logged, recoverable).
|
||||
/// </summary>
|
||||
public async Task<Result> SetHeroImageAsync(long releaseId, IFormFile imageFile, CancellationToken ct)
|
||||
{
|
||||
if (MimeTypeExtensions.GetExtension(imageFile.ContentType) == ".bin")
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SetHeroImage rejected: unsupported content type '{ContentType}' for release {ReleaseId}",
|
||||
imageFile.ContentType, releaseId);
|
||||
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
|
||||
}
|
||||
|
||||
byte[] buffer;
|
||||
await using (var stream = imageFile.OpenReadStream())
|
||||
using (var memory = new MemoryStream())
|
||||
{
|
||||
await stream.CopyToAsync(memory, ct);
|
||||
buffer = memory.ToArray();
|
||||
}
|
||||
|
||||
var imageBinary = _imageProcessor.Process(buffer, imageFile.ContentType);
|
||||
if (imageBinary is null)
|
||||
{
|
||||
_logger.LogWarning("SetHeroImage: ImageProcessor rejected content type '{ContentType}'", imageFile.ContentType);
|
||||
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
|
||||
}
|
||||
|
||||
var entryKey = Guid.NewGuid().ToString("N");
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("SetHeroImage: vault write failed for release {ReleaseId}, entryKey={EntryKey}", releaseId, entryKey);
|
||||
return Result.CreateFailResult("Failed to store hero image.");
|
||||
}
|
||||
|
||||
var linked = await _releaseService.SetSessionHeroImageAsync(releaseId, entryKey, ct);
|
||||
if (!linked.Success)
|
||||
{
|
||||
// Vault write succeeded, SQL link failed — image is orphaned in the Images vault under
|
||||
// entryKey. Log loudly (include entryKey) so it is recoverable manually.
|
||||
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Hero image stored in vault but Session link failed. Orphaned entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
|
||||
entryKey, releaseId, error);
|
||||
return linked;
|
||||
}
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch the Mix's track audio from the vault, compute a high-res (2048-bucket) waveform datum,
|
||||
/// store it in the MixWaveforms vault under the track's EntryKey, then point the release's Mix
|
||||
/// satellite at that same key. The datum key equals the track's EntryKey — the Mix is single-track.
|
||||
/// </summary>
|
||||
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _releaseService.GetByIdAsync(releaseId, ct);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("TriggerMixWaveform: release lookup failed for {ReleaseId}: {Error}", releaseId, error);
|
||||
return Result.CreateFailResult("Failed to load release.");
|
||||
}
|
||||
|
||||
if (lookup.Value is null)
|
||||
return Result.CreateFailResult(ReleaseManager.ReleaseNotFoundMessage);
|
||||
|
||||
// Pre-check medium here (before fetching audio) to avoid expensive waveform compute on a
|
||||
// non-Mix release. ReleaseManager.SetMixWaveformAsync enforces this too, so the double-check
|
||||
// is intentional — the orchestrator's guard is the cheap early-exit.
|
||||
if (lookup.Value.Medium != ReleaseMedium.Mix)
|
||||
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
|
||||
|
||||
var keysResult = await _releaseService.GetTrackEntryKeysAsync(releaseId, ct);
|
||||
if (!keysResult.Success || keysResult.Value is null)
|
||||
{
|
||||
var error = keysResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("TriggerMixWaveform: entry-key lookup failed for release {ReleaseId}: {Error}", releaseId, error);
|
||||
return Result.CreateFailResult("Failed to load release tracks.");
|
||||
}
|
||||
|
||||
var entryKey = keysResult.Value.FirstOrDefault();
|
||||
if (entryKey is null)
|
||||
{
|
||||
_logger.LogWarning("TriggerMixWaveform: no track on Mix release {ReleaseId}", releaseId);
|
||||
return Result.CreateFailResult(MixHasNoTrackMessage);
|
||||
}
|
||||
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(entryKey);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
|
||||
return Result.CreateFailResult(MixTrackNoAudioMessage);
|
||||
}
|
||||
|
||||
var computed = await _waveformProfileService.ComputeAndStoreAsync(
|
||||
audio.Buffer, entryKey, MixWaveformBucketCount, VaultConstants.MixWaveforms);
|
||||
if (!computed)
|
||||
{
|
||||
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
|
||||
return Result.CreateFailResult("Failed to compute the Mix waveform.");
|
||||
}
|
||||
|
||||
var linked = await _releaseService.SetMixWaveformAsync(releaseId, entryKey, ct);
|
||||
if (!linked.Success)
|
||||
{
|
||||
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Mix waveform stored in vault but Mix link failed. Entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
|
||||
entryKey, releaseId, error);
|
||||
return linked;
|
||||
}
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ namespace DeepDrftAPI
|
||||
if (db is null) throw new Exception("Unable to initialize file database");
|
||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||
InitializeImageVault(db).GetAwaiter().GetResult();
|
||||
InitializeMixWaveformsVault(db).GetAwaiter().GetResult();
|
||||
return db;
|
||||
});
|
||||
|
||||
@@ -64,5 +65,14 @@ namespace DeepDrftAPI
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the mix-waveforms vault exists. Holds high-resolution waveform datums for DJ Mix releases.
|
||||
private static async Task InitializeMixWaveformsVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.MixWaveforms))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.MixWaveforms, MediaVaultType.Media);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,10 @@ public static class VaultConstants
|
||||
/// from <c>TrackEntity.ImagePath</c>.
|
||||
/// </summary>
|
||||
public const string Images = "images";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for Mix high-resolution waveform datums, keyed by the mix track's EntryKey.
|
||||
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
|
||||
/// </summary>
|
||||
public const string MixWaveforms = "mix-waveforms";
|
||||
}
|
||||
@@ -39,12 +39,22 @@ public class WaveformProfileService
|
||||
|
||||
/// <summary>
|
||||
/// Computes the loudness profile from <paramref name="wavBytes"/> and stores it under
|
||||
/// <paramref name="entryKey"/>. Returns false (and logs) on any failure — a missing profile
|
||||
/// is handled gracefully downstream, so callers on the upload path log-and-continue rather
|
||||
/// than failing the upload. Does not throw for expected failure modes.
|
||||
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
|
||||
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
|
||||
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
|
||||
/// pass a higher value (e.g., 2048) for the Mix high-res datum. Returns false (and logs) on any
|
||||
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
|
||||
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
|
||||
/// </summary>
|
||||
public async Task<bool> ComputeAndStoreAsync(ReadOnlyMemory<byte> wavBytes, string entryKey)
|
||||
public async Task<bool> ComputeAndStoreAsync(
|
||||
ReadOnlyMemory<byte> wavBytes,
|
||||
string entryKey,
|
||||
int? bucketCount = null,
|
||||
string? vaultName = null)
|
||||
{
|
||||
var effectiveBucketCount = bucketCount ?? _options.BucketCount;
|
||||
var effectiveVaultName = vaultName ?? VaultConstants.WaveformProfiles;
|
||||
|
||||
try
|
||||
{
|
||||
var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span);
|
||||
@@ -62,15 +72,14 @@ public class WaveformProfileService
|
||||
value.Channels,
|
||||
value.SampleRate,
|
||||
value.BitsPerSample,
|
||||
_options.BucketCount);
|
||||
effectiveBucketCount);
|
||||
|
||||
var quantized = Quantize(profile);
|
||||
|
||||
await EnsureVaultAsync();
|
||||
await EnsureVaultAsync(effectiveVaultName);
|
||||
|
||||
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(
|
||||
VaultConstants.WaveformProfiles, entryKey, binary);
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(effectiveVaultName, entryKey, binary);
|
||||
|
||||
if (!stored)
|
||||
{
|
||||
@@ -88,14 +97,15 @@ public class WaveformProfileService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored quantized profile bytes for a track, or null if no profile is stored
|
||||
/// (existing tracks predate profiling, and computation may have failed). Each byte is a
|
||||
/// peak-normalized loudness value in [0, 255].
|
||||
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
|
||||
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
|
||||
/// is stored (existing tracks predate profiling, and computation may have failed). Each byte is
|
||||
/// a peak-normalized loudness value in [0, 255].
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetProfileAsync(string entryKey)
|
||||
public async Task<byte[]?> GetProfileAsync(string entryKey, string? vaultName = null)
|
||||
{
|
||||
var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>(
|
||||
VaultConstants.WaveformProfiles, entryKey);
|
||||
vaultName ?? VaultConstants.WaveformProfiles, entryKey);
|
||||
return binary?.Buffer;
|
||||
}
|
||||
|
||||
@@ -113,11 +123,11 @@ public class WaveformProfileService
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task EnsureVaultAsync()
|
||||
private async Task EnsureVaultAsync(string vaultName)
|
||||
{
|
||||
if (!_fileDatabase.HasVault(VaultConstants.WaveformProfiles))
|
||||
if (!_fileDatabase.HasVault(vaultName))
|
||||
{
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.WaveformProfiles, MediaVaultType.Media);
|
||||
await _fileDatabase.CreateVaultAsync(vaultName, MediaVaultType.Media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// SQL-side release service. Repository outputs entities; this service outputs DTOs via TrackConverter.
|
||||
/// Backs the medium-aware release read endpoints (paged list + by-id detail) and the two metadata
|
||||
/// write paths (Session hero image, Mix waveform). The entity never escapes the service layer.
|
||||
/// </summary>
|
||||
public interface IReleaseService
|
||||
{
|
||||
/// <summary>Paginated releases, optionally filtered to one medium. The matching medium's metadata satellite is included in the result. Omit medium for all releases.</summary>
|
||||
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
ReleaseMedium? medium, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary>
|
||||
Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Find-or-create the Session satellite and set its hero image entry key. Fails when the release is not a Session.</summary>
|
||||
Task<Result> SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Find-or-create the Mix satellite and set its waveform entry key. Fails when the release is not a Mix.</summary>
|
||||
Task<Result> SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Linq.Expressions;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// SQL-side release service implementing <see cref="IReleaseService"/>. Deliberately does NOT extend
|
||||
/// <c>Manager<></c>: that CRUD base does not fit this read-projection + satellite-write purpose.
|
||||
/// The layer boundary holds — ReleaseRepository outputs entities, this manager outputs DTOs via
|
||||
/// TrackConverter, the single authoritative conversion path.
|
||||
/// </summary>
|
||||
public class ReleaseManager : IReleaseService
|
||||
{
|
||||
// Distinguishes "release does not exist" from a real failure so the controller can map to 404.
|
||||
public const string ReleaseNotFoundMessage = "Release not found.";
|
||||
|
||||
private readonly ReleaseRepository _repository;
|
||||
private readonly ILogger<ReleaseManager> _logger;
|
||||
|
||||
public ReleaseManager(ReleaseRepository repository, ILogger<ReleaseManager> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Nulls sort to end via the coalescing sentinels, matching TrackManager's convention.
|
||||
private static Expression<Func<ReleaseEntity, object>> GetOrderExpression(string? sortColumn)
|
||||
=> sortColumn switch
|
||||
{
|
||||
"Title" => r => r.Title,
|
||||
"Artist" => r => r.Artist,
|
||||
"ReleaseDate" => r => (object)(r.ReleaseDate ?? DateOnly.MaxValue),
|
||||
"Medium" => r => r.Medium,
|
||||
_ => r => r.Id
|
||||
};
|
||||
|
||||
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
ReleaseMedium? medium, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameters = new PagingParameters<ReleaseEntity>
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
OrderBy = GetOrderExpression(sortColumn),
|
||||
IsDescending = sortDescending,
|
||||
};
|
||||
|
||||
var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, cancellationToken);
|
||||
|
||||
var releaseIds = entityPage.Items.Select(r => r.Id).ToList();
|
||||
var counts = await _repository.GetTrackCountsByReleaseIdsAsync(releaseIds, cancellationToken);
|
||||
|
||||
var dtos = entityPage.Items
|
||||
.Select(r =>
|
||||
{
|
||||
var dto = TrackConverter.Convert(r);
|
||||
dto.TrackCount = counts.GetValueOrDefault(r.Id);
|
||||
return dto;
|
||||
});
|
||||
|
||||
var dtoPage = PagedResult<ReleaseDto>.From(entityPage, dtos);
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreatePassResult(dtoPage);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _repository.GetByIdWithMetadataAsync(id, cancellationToken);
|
||||
// TrackConverter nulls the non-matching satellite. TrackCount is not loaded for the detail
|
||||
// read (the Tracks collection isn't Include'd) and is not needed by detail consumers.
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var keys = await _repository.GetTrackEntryKeysByReleaseIdAsync(releaseId, cancellationToken);
|
||||
return ResultContainer<List<string>>.CreatePassResult(keys);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<string>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken);
|
||||
if (release is null)
|
||||
return Result.CreateFailResult(ReleaseNotFoundMessage);
|
||||
|
||||
if (release.Medium != ReleaseMedium.Session)
|
||||
return Result.CreateFailResult($"Release {releaseId} is not a Session medium.");
|
||||
|
||||
await _repository.SetHeroImageEntryKeyAsync(releaseId, heroImageEntryKey, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken);
|
||||
if (release is null)
|
||||
return Result.CreateFailResult(ReleaseNotFoundMessage);
|
||||
|
||||
if (release.Medium != ReleaseMedium.Mix)
|
||||
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
|
||||
|
||||
await _repository.SetWaveformEntryKeyAsync(releaseId, waveformEntryKey, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftData.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Medium-aware release queries. Deliberately does NOT extend <c>Repository<DeepDrftContext, ReleaseEntity></c>:
|
||||
/// that base is generic CRUD, while this repository's purpose is read-projection (paged, medium-filtered)
|
||||
/// and satellite-row management (Session/Mix metadata find-or-create). Injects <see cref="DeepDrftContext"/>
|
||||
/// directly so reads/writes stay in one unit of work.
|
||||
/// </summary>
|
||||
public class ReleaseRepository
|
||||
{
|
||||
private readonly DeepDrftContext _context;
|
||||
private readonly ILogger<ReleaseRepository> _logger;
|
||||
|
||||
public ReleaseRepository(DeepDrftContext context, ILogger<ReleaseRepository> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Single location where the medium↔metadata correlation is determined on a list read: a satellite
|
||||
// is loaded only when the caller's medium filter matches it. TrackConverter.Convert(ReleaseEntity)
|
||||
// enforces the same rule at the DTO boundary (nulling non-matching satellites); this map ensures a
|
||||
// non-matching satellite is never even queried. Cut (or no filter) loads no satellite on list reads.
|
||||
private static IQueryable<ReleaseEntity> ApplyMediumInclude(IQueryable<ReleaseEntity> query, ReleaseMedium? medium)
|
||||
=> medium switch
|
||||
{
|
||||
ReleaseMedium.Session => query.Include(r => r.SessionMetadata),
|
||||
ReleaseMedium.Mix => query.Include(r => r.MixMetadata),
|
||||
_ => query
|
||||
};
|
||||
|
||||
// Paged, optionally medium-filtered release list. The matching medium's satellite is Include'd;
|
||||
// total count reflects the medium filter (applied before Skip/Take).
|
||||
public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
|
||||
PagingParameters<ReleaseEntity> paging,
|
||||
ReleaseMedium? medium,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
|
||||
if (medium.HasValue)
|
||||
query = query.Where(r => r.Medium == medium.Value);
|
||||
|
||||
query = ApplyMediumInclude(query, medium);
|
||||
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
|
||||
if (paging.OrderBy is not null)
|
||||
query = paging.IsDescending ? query.OrderByDescending(paging.OrderBy) : query.OrderBy(paging.OrderBy);
|
||||
|
||||
var items = await query.Skip(paging.Skip).Take(paging.PageSize).ToListAsync(ct);
|
||||
|
||||
return new PagedResult<ReleaseEntity>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
Page = paging.Page,
|
||||
PageSize = paging.PageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// Single release with both satellites Include'd: the medium is unknown until fetched, and both are
|
||||
// 1:1 FK-indexed joins. TrackConverter nulls the non-matching satellite at the DTO boundary.
|
||||
public async Task<ReleaseEntity?> GetByIdWithMetadataAsync(long id, CancellationToken ct)
|
||||
=> await _context.Releases
|
||||
.Where(r => r.Id == id && !r.IsDeleted)
|
||||
.Include(r => r.SessionMetadata)
|
||||
.Include(r => r.MixMetadata)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
|
||||
// list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
|
||||
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
|
||||
IEnumerable<long> releaseIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ids = releaseIds.ToList();
|
||||
return await _context.Tracks
|
||||
.Where(t => !t.IsDeleted && t.ReleaseId != null && ids.Contains(t.ReleaseId.Value))
|
||||
.GroupBy(t => t.ReleaseId!.Value)
|
||||
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
||||
}
|
||||
|
||||
// Vault entry keys of the non-deleted tracks on a release, track-number ascending. Single-entry for
|
||||
// Session/Mix (enforced at upload); may be multiple for Cut.
|
||||
public async Task<List<string>> GetTrackEntryKeysByReleaseIdAsync(long releaseId, CancellationToken ct)
|
||||
=> await _context.Tracks
|
||||
.Where(t => !t.IsDeleted && t.ReleaseId == releaseId)
|
||||
.OrderBy(t => t.TrackNumber)
|
||||
.Select(t => t.EntryKey)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Find-or-create the Session satellite for a release and set its hero-image entry key. The 1:1 FK
|
||||
// makes (ReleaseId) the natural key; a repeat call updates the existing row in place.
|
||||
public async Task SetHeroImageEntryKeyAsync(long releaseId, string heroImageEntryKey, CancellationToken ct)
|
||||
{
|
||||
var existing = await _context.SessionMetadata.FirstOrDefaultAsync(s => s.ReleaseId == releaseId, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.HeroImageEntryKey = heroImageEntryKey;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
_context.SessionMetadata.Add(new SessionMetadata
|
||||
{
|
||||
ReleaseId = releaseId,
|
||||
HeroImageEntryKey = heroImageEntryKey,
|
||||
});
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Find-or-create the Mix satellite for a release and set its waveform entry key. Same 1:1 find-or-create
|
||||
// pattern as SetHeroImageEntryKeyAsync.
|
||||
public async Task SetWaveformEntryKeyAsync(long releaseId, string waveformEntryKey, CancellationToken ct)
|
||||
{
|
||||
var existing = await _context.MixMetadata.FirstOrDefaultAsync(m => m.ReleaseId == releaseId, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.WaveformEntryKey = waveformEntryKey;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
_context.MixMetadata.Add(new MixMetadata
|
||||
{
|
||||
ReleaseId = releaseId,
|
||||
WaveformEntryKey = waveformEntryKey,
|
||||
});
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user