feat: add search/album/genre filtering and /albums + /genres browse pages
This commit is contained in:
@@ -20,7 +20,14 @@ public interface ITrackService
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default);
|
||||
Task<ResultContainer<List<TrackDto>>> GetAll();
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Distinct non-null albums with track counts and a representative cover key, album-ascending.</summary>
|
||||
Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
||||
Task<ResultContainer<TrackDto>> Update(TrackDto track);
|
||||
Task<Result> Delete(long id);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,6 +97,7 @@ public class TrackManager
|
||||
int pageSize,
|
||||
string? sortColumn,
|
||||
bool sortDescending,
|
||||
TrackFilter? filter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -117,7 +118,14 @@ public class TrackManager
|
||||
}
|
||||
};
|
||||
|
||||
var page = await Repository.GetPagedAsync(parameters);
|
||||
// An all-null filter must produce identical results to no filter, so collapse it to
|
||||
// null and take the unfiltered base path (preserves backward compatibility).
|
||||
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
|
||||
|
||||
var page = effectiveFilter is null
|
||||
? await Repository.GetPagedAsync(parameters)
|
||||
: await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
|
||||
|
||||
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
|
||||
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
|
||||
}
|
||||
@@ -127,6 +135,32 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var albums = await Repository.GetDistinctAlbumsAsync(cancellationToken);
|
||||
return ResultContainer<List<AlbumSummaryDto>>.CreatePassResult(albums);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<AlbumSummaryDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var genres = await Repository.GetDistinctGenresAsync(cancellationToken);
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user