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:
daniel-c-harvey
2026-06-12 22:13:31 -04:00
parent 93dcc59814
commit ca44fc8794
9 changed files with 718 additions and 16 deletions
+31
View File
@@ -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);
}
+147
View File
@@ -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&lt;&gt;</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&lt;DeepDrftContext, ReleaseEntity&gt;</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);
}
}