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.
This commit is contained in:
daniel-c-harvey
2026-06-13 20:47:50 -04:00
parent 18f4b596f2
commit 737c423d9c
13 changed files with 607 additions and 59 deletions
+2 -2
View File
@@ -12,10 +12,10 @@ namespace DeepDrftData;
/// </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>
/// <summary>Paginated releases, optionally narrowed by medium and a free-text/genre filter. The matching medium's metadata satellite is included in the result. Omit medium for all releases; omit filter for no search/genre narrowing.</summary>
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
ReleaseMedium? medium, CancellationToken cancellationToken = default);
ReleaseMedium? medium, ReleaseFilter? filter = null, 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);
+4 -2
View File
@@ -42,7 +42,7 @@ public class ReleaseManager : IReleaseService
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
ReleaseMedium? medium, CancellationToken cancellationToken = default)
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default)
{
try
{
@@ -54,7 +54,9 @@ public class ReleaseManager : IReleaseService
IsDescending = sortDescending,
};
var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, cancellationToken);
// 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);
+23 -2
View File
@@ -1,4 +1,5 @@
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
@@ -36,17 +37,37 @@ public class ReleaseRepository
_ => 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).
// Paged release list, optionally narrowed by medium and a free-text/genre filter. The matching
// medium's satellite is Include'd; total count reflects every applied predicate (all before
// Skip/Take). The filter predicates mirror TrackRepository.GetPagedFilteredAsync so the release
// browse path searches and filters identically to the track path.
public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
PagingParameters<ReleaseEntity> paging,
ReleaseMedium? medium,
ReleaseFilter? filter,
CancellationToken ct)
{
IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
if (medium.HasValue)
query = query.Where(r => r.Medium == medium.Value);
if (filter is not null)
{
if (!string.IsNullOrWhiteSpace(filter.SearchText))
{
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
// EF-translatable where ToLower().Contains() is not. Title/Artist are non-null columns
// on the release itself, so no navigation guard is needed (unlike the track path).
var pattern = $"%{filter.SearchText}%";
query = query.Where(r =>
EF.Functions.ILike(r.Title, pattern)
|| EF.Functions.ILike(r.Artist, pattern));
}
if (!string.IsNullOrWhiteSpace(filter.Genre))
query = query.Where(r => r.Genre == filter.Genre);
}
query = ApplyMediumInclude(query, medium);
var totalCount = await query.CountAsync(ct);