Files
deepdrft/DeepDrftData/ReleaseManager.cs
T
daniel-c-harvey 737c423d9c feat: replace /archive with release-cardinal searchable browser (Phase 9 §8.H)
Retire the three-card overview for a search + medium + genre browser over all
releases. Adds q/genre filter params to the api/release paged read path,
mirroring the existing api/track/page TrackFilter pattern.
2026-06-13 20:47:50 -04:00

150 lines
5.9 KiB
C#

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, ReleaseFilter? filter = null, CancellationToken cancellationToken = default)
{
try
{
var parameters = new PagingParameters<ReleaseEntity>
{
Page = page,
PageSize = pageSize,
OrderBy = GetOrderExpression(sortColumn),
IsDescending = sortDescending,
};
// Collapse an all-null filter to null so the repository skips the predicate block entirely.
var effectiveFilter = filter is { IsEmpty: false } ? filter : null;
var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, effectiveFilter, 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);
}
}
}