feat: add search/album/genre filtering and /albums + /genres browse pages

This commit is contained in:
daniel-c-harvey
2026-06-10 10:54:56 -04:00
parent 1071ba7374
commit 5cae83b9ed
24 changed files with 940 additions and 15 deletions
@@ -1,9 +1,11 @@
using Data.Data.Repositories;
using Data.Errors;
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Models.Common;
namespace DeepDrftData.Repositories;
@@ -44,6 +46,94 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
.FirstOrDefaultAsync(cancellationToken);
}
// Paged query with optional filter predicates. Built directly off the DbSet rather than the
// base GetPagedAsync(paging) overload, which takes no where-clause. The OrderBy expression and
// direction ride in on the PagingParameters the manager already built, so sort + filter +
// pagination compose. Filter predicates apply before sort and Skip/Take so TotalCount reflects
// the filtered set.
public async Task<PagedResult<TrackEntity>> GetPagedFilteredAsync(
PagingParameters<TrackEntity> paging,
TrackFilter? filter,
CancellationToken ct = default)
{
IQueryable<TrackEntity> query = _context.Tracks;
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. Album is nullable — ILike on a
// null column yields false, which is the desired "no match" behaviour.
var pattern = $"%{filter.SearchText}%";
query = query.Where(t =>
EF.Functions.ILike(t.TrackName, pattern)
|| EF.Functions.ILike(t.Artist, pattern)
|| (t.Album != null && EF.Functions.ILike(t.Album, pattern)));
}
if (!string.IsNullOrWhiteSpace(filter.Album))
query = query.Where(t => t.Album == filter.Album);
if (!string.IsNullOrWhiteSpace(filter.Genre))
query = query.Where(t => t.Genre == filter.Genre);
}
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<TrackEntity>
{
Items = items,
TotalCount = totalCount,
Page = paging.Page,
PageSize = paging.PageSize,
};
}
// Distinct albums (non-null) with track counts and a representative cover key. The cover is the
// first non-null ImagePath in the group; GroupBy + projection keeps it a single round-trip.
public async Task<List<AlbumSummaryDto>> GetDistinctAlbumsAsync(CancellationToken ct = default)
=> await _context.Tracks
.Where(t => t.Album != null)
.GroupBy(t => t.Album!)
.Select(g => new AlbumSummaryDto
{
Album = g.Key,
TrackCount = g.Count(),
CoverImageKey = g
.Where(t => t.ImagePath != null)
.OrderBy(t => t.Id)
.Select(t => t.ImagePath)
.FirstOrDefault(),
})
.OrderBy(a => a.Album)
.ToListAsync(ct);
// Distinct genres (non-null) with track counts.
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
=> await _context.Tracks
.Where(t => t.Genre != null)
.GroupBy(t => t.Genre!)
.Select(g => new GenreSummaryDto
{
Genre = g.Key,
TrackCount = g.Count(),
})
.OrderBy(g => g.Genre)
.ToListAsync(ct);
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
{
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted