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
+43 -2
View File
@@ -47,17 +47,24 @@ public class TrackController : ControllerBase
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route // These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId. // resolution never treats "page", "upload", or "meta" as a trackId.
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false // GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}. // Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
// q/album/genre build an optional TrackFilter; all null → null passthrough (no filtering).
[HttpGet("page")] [HttpGet("page")]
public async Task<ActionResult> GetPage( public async Task<ActionResult> GetPage(
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null, [FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false, [FromQuery] bool sortDescending = false,
[FromQuery] string? q = null,
[FromQuery] string? album = null,
[FromQuery] string? genre = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, cancellationToken); var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre };
var effectiveFilter = filter.IsEmpty ? null : filter;
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
if (!result.Success || result.Value is null) if (!result.Success || result.Value is null)
{ {
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
@@ -68,6 +75,40 @@ public class TrackController : ControllerBase
return Ok(result.Value); return Ok(result.Value);
} }
// GET api/track/albums (unauthenticated)
// Distinct non-null albums with track counts and cover keys. Public browse data, same posture as
// GET api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
[HttpGet("albums")]
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
{
var result = await _sqlTrackService.GetDistinctAlbums(ct);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetAlbums failed: {Error}", error);
return StatusCode(500, "Failed to load albums");
}
return Ok(result.Value);
}
// GET api/track/genres (unauthenticated)
// Distinct non-null genres with track counts. Public browse data, same posture as GET
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
[HttpGet("genres")]
public async Task<ActionResult> GetGenres(CancellationToken ct = default)
{
var result = await _sqlTrackService.GetDistinctGenres(ct);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetGenres failed: {Error}", error);
return StatusCode(500, "Failed to load genres");
}
return Ok(result.Value);
}
// GET api/track/random (unauthenticated) // GET api/track/random (unauthenticated)
// Picks one track at random from the full library and returns its metadata. Public, same auth // Picks one track at random from the full library and returns its metadata. Public, same auth
// posture as GET api/track/page. Selection math lives in the SQL service/repository, not here. // posture as GET api/track/page. Selection math lives in the SQL service/repository, not here.
+8 -1
View File
@@ -20,7 +20,14 @@ public interface ITrackService
/// </summary> /// </summary>
Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default); Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default);
Task<ResultContainer<List<TrackDto>>> GetAll(); 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>> Create(TrackDto newTrack);
Task<ResultContainer<TrackDto>> Update(TrackDto track); Task<ResultContainer<TrackDto>> Update(TrackDto track);
Task<Result> Delete(long id); Task<Result> Delete(long id);
@@ -1,9 +1,11 @@
using Data.Data.Repositories; using Data.Data.Repositories;
using Data.Errors; using Data.Errors;
using DeepDrftData.Data; using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities; using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Models.Common;
namespace DeepDrftData.Repositories; namespace DeepDrftData.Repositories;
@@ -44,6 +46,94 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
.FirstOrDefaultAsync(cancellationToken); .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) protected override void UpdateEntity(TrackEntity target, TrackEntity source)
{ {
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
+35 -1
View File
@@ -97,6 +97,7 @@ public class TrackManager
int pageSize, int pageSize,
string? sortColumn, string? sortColumn,
bool sortDescending, bool sortDescending,
TrackFilter? filter = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
try 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)); var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage); 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) public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
{ {
try try
+14
View File
@@ -0,0 +1,14 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// One distinct album with its track count and a representative cover image key. Backs the
/// /albums browse grid.
/// </summary>
public class AlbumSummaryDto
{
public required string Album { get; set; }
public int TrackCount { get; set; }
/// <summary>ImagePath of the first track in the album that has one; null when none do.</summary>
public string? CoverImageKey { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace DeepDrftModels.DTOs;
/// <summary>One distinct genre with its track count. Backs the /genres browse list.</summary>
public class GenreSummaryDto
{
public required string Genre { get; set; }
public int TrackCount { get; set; }
}
+27
View File
@@ -0,0 +1,27 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Cross-project track filter contract. Threaded alongside (never inside) the external
/// <c>PagingParameters&lt;T&gt;</c>, which cannot carry a where-clause. An instance with all
/// properties null is equivalent to no filter — see <c>TrackFilter.IsEmpty</c>.
/// </summary>
public class TrackFilter
{
/// <summary>Free-text, case-insensitive LIKE across TrackName, Artist, and Album.</summary>
public string? SearchText { get; set; }
/// <summary>Exact album match.</summary>
public string? Album { get; set; }
/// <summary>Exact genre match.</summary>
public string? Genre { get; set; }
/// <summary>
/// True when no predicate is set. An empty filter must produce identical results to a null
/// filter, so callers collapse it to null before querying.
/// </summary>
public bool IsEmpty =>
string.IsNullOrWhiteSpace(SearchText)
&& string.IsNullOrWhiteSpace(Album)
&& string.IsNullOrWhiteSpace(Genre);
}
+49 -1
View File
@@ -20,7 +20,10 @@ public class TrackClient
int pageNumber, int pageNumber,
int pageSize, int pageSize,
string? sortColumn = null, string? sortColumn = null,
bool sortDescending = false) bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null)
{ {
var queryArgs = new Dictionary<string, string?>(){ var queryArgs = new Dictionary<string, string?>(){
["page"] = pageNumber.ToString(), ["page"] = pageNumber.ToString(),
@@ -33,6 +36,15 @@ public class TrackClient
if (sortDescending) if (sortDescending)
queryArgs["sortDescending"] = "true"; queryArgs["sortDescending"] = "true";
if (!string.IsNullOrEmpty(searchText))
queryArgs["q"] = searchText;
if (!string.IsNullOrEmpty(album))
queryArgs["album"] = album;
if (!string.IsNullOrEmpty(genre))
queryArgs["genre"] = genre;
string query = QueryString.Create(queryArgs).ToString(); string query = QueryString.Create(queryArgs).ToString();
var response = await _http.GetAsync($"api/track/page{query}"); var response = await _http.GetAsync($"api/track/page{query}");
@@ -77,6 +89,42 @@ public class TrackClient
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response"); : ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
} }
public async Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
{
var response = await _http.GetAsync("api/track/albums");
if (!response.IsSuccessStatusCode)
return ApiResult<List<AlbumSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var albums = JsonSerializer.Deserialize<List<AlbumSummaryDto>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return albums is not null
? ApiResult<List<AlbumSummaryDto>>.CreatePassResult(albums)
: ApiResult<List<AlbumSummaryDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
{
var response = await _http.GetAsync("api/track/genres");
if (!response.IsSuccessStatusCode)
return ApiResult<List<GenreSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var genres = JsonSerializer.Deserialize<List<GenreSummaryDto>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return genres is not null
? ApiResult<List<GenreSummaryDto>>.CreatePassResult(genres)
: ApiResult<List<GenreSummaryDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey) public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{ {
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}"); var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
+5 -3
View File
@@ -13,9 +13,11 @@ public static class Pages
{ {
public static readonly List<PageRoute> MenuPages = public static readonly List<PageRoute> MenuPages =
[ [
new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic }, new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic },
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships new() { Name = "Albums", Route = "/albums", Icon = Icons.Material.Filled.Album },
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships new() { Name = "Genres", Route = "/genres", Icon = Icons.Material.Filled.Category },
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships
]; ];
public static readonly List<PageRoute> AllPages = public static readonly List<PageRoute> AllPages =
@@ -0,0 +1,63 @@
@page "/albums"
<PageTitle>DeepDrft Albums</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container">
@if (_loading)
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="album-card-center">
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
}
</MudGrid>
}
else if (_albums.Count == 0)
{
<div class="albums-empty">
<MudText Typo="Typo.h6">No albums yet</MudText>
</div>
}
else
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var album in _albums)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="album-card-center">
<div class="album-card"
role="button"
tabindex="0"
@onclick="@(() => OpenAlbum(album.Album))">
@if (!string.IsNullOrEmpty(album.CoverImageKey))
{
<div class="album-card-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(album.CoverImageKey)');">
</div>
}
else
{
<div class="album-card-cover album-card-cover--fallback"></div>
}
<div class="album-card-body">
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
@album.Album
</MudText>
<MudText Typo="Typo.caption" Class="album-card-count">
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
</MudText>
</div>
</div>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
</div>
@@ -0,0 +1,26 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
public partial class AlbumsView : ComponentBase
{
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
private bool _loading = true;
private List<AlbumSummaryDto> _albums = [];
protected override async Task OnInitializedAsync()
{
var result = await TrackData.GetAlbums();
if (result is { Success: true, Value: { } albums })
_albums = albums;
_loading = false;
}
private void OpenAlbum(string album)
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
}
@@ -0,0 +1,58 @@
.albums-view-container {
padding-top: 16px;
}
.album-card-center {
display: flex;
justify-content: center;
width: 100%;
}
.album-card {
display: flex;
flex-direction: column;
width: 200px;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 120ms ease;
}
.album-card:hover {
transform: translateY(-4px);
}
.album-card-cover {
width: 200px;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.album-card-cover--fallback {
background-color: var(--mud-palette-dark, #1a2238);
}
.album-card-body {
padding: 8px 4px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* album-card-title / album-card-count ride on MudText, a child Razor component whose
root Blazor isolation does not scope-stamp; ::deep pierces into its output. */
::deep .album-card-title {
font-weight: 600;
}
::deep .album-card-count {
opacity: 0.7;
}
.albums-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
@@ -0,0 +1,41 @@
@page "/genres"
<PageTitle>DeepDrft Genres</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Medium" Class="genres-view-container">
@if (_loading)
{
<div class="genres-list">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudSkeleton Height="48px" Width="100%" Class="mb-2"/>
}
</div>
}
else if (_genres.Count == 0)
{
<div class="genres-empty">
<MudText Typo="Typo.h6">No genres yet</MudText>
</div>
}
else
{
<MudList T="string" Class="genres-list">
@foreach (var genre in _genres)
{
<MudListItem T="string"
Icon="@Icons.Material.Filled.Category"
OnClick="@(() => OpenGenre(genre.Genre))">
<div class="genre-row">
<MudText Typo="Typo.subtitle1">@genre.Genre</MudText>
<MudText Typo="Typo.caption" Class="genre-count">
@genre.TrackCount @(genre.TrackCount == 1 ? "track" : "tracks")
</MudText>
</div>
</MudListItem>
}
</MudList>
}
</MudContainer>
</div>
@@ -0,0 +1,26 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
public partial class GenresView : ComponentBase
{
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
private bool _loading = true;
private List<GenreSummaryDto> _genres = [];
protected override async Task OnInitializedAsync()
{
var result = await TrackData.GetGenres();
if (result is { Success: true, Value: { } genres })
_genres = genres;
_loading = false;
}
private void OpenGenre(string genre)
=> Navigation.NavigateTo($"/tracks?genre={Uri.EscapeDataString(genre)}");
}
@@ -0,0 +1,28 @@
.genres-view-container {
padding-top: 16px;
}
/* genres-list rides on MudList, a child Razor component whose root Blazor isolation
does not scope-stamp; ::deep is required. */
::deep .genres-list {
width: 100%;
}
.genre-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
width: 100%;
}
/* genre-count rides on MudText (child Razor component); ::deep pierces into its output. */
::deep .genre-count {
opacity: 0.7;
}
.genres-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
@@ -5,6 +5,41 @@
<div> <div>
<div class="tracks-view-container"> <div class="tracks-view-container">
@* Search + filter affordances are interactive-only: the debounce timer and pill clear
need WASM. During prerender/non-interactive they are hidden, matching the view-mode
toggle's interactivity gate. *@
@if (RendererInfo.IsInteractive)
{
<div class="tracks-search-row">
<MudTextField T="string"
Value="@ViewModel.SearchText"
ValueChanged="@OnSearchInput"
Immediate="true"
DebounceInterval="400"
Placeholder="Search tracks, artists, albums"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
Class="tracks-search-field"/>
</div>
@if (ViewModel.FilterAlbum is not null || ViewModel.FilterGenre is not null)
{
<div class="tracks-filter-pills">
<MudChip T="string"
Color="Color.Tertiary"
Variant="Variant.Filled"
OnClose="@(_ => ClearFilter())">
@(ViewModel.FilterAlbum is not null
? $"Album: {ViewModel.FilterAlbum}"
: $"Genre: {ViewModel.FilterGenre}")
</MudChip>
</div>
}
}
@if (ViewModel.Page != null) @if (ViewModel.Page != null)
{ {
<div class="tracks-view-header"> <div class="tracks-view-header">
@@ -13,8 +13,15 @@ public partial class TracksView : ComponentBase, IDisposable
[Inject] public required TracksViewModel ViewModel { get; set; } [Inject] public required TracksViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; } [CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
// Filter params arrive on the URL: /tracks?album=X, /tracks?genre=Y, /tracks?q=Z. Copied into
// the ViewModel on init before the first fetch so the gallery renders filtered on direct nav.
[SupplyParameterFromQuery(Name = "album")] public string? AlbumQuery { get; set; }
[SupplyParameterFromQuery(Name = "genre")] public string? GenreQuery { get; set; }
[SupplyParameterFromQuery(Name = "q")] public string? SearchQuery { get; set; }
private IStreamingPlayerService? _subscribedService; private IStreamingPlayerService? _subscribedService;
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
@@ -23,6 +30,11 @@ public partial class TracksView : ComponentBase, IDisposable
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// Seed filter state from the URL before any fetch or restore decision.
ViewModel.FilterAlbum = string.IsNullOrWhiteSpace(AlbumQuery) ? null : AlbumQuery;
ViewModel.FilterGenre = string.IsNullOrWhiteSpace(GenreQuery) ? null : GenreQuery;
ViewModel.SearchText = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery;
// Carry the prerendered page across the prerender -> interactive (WASM) seam. // Carry the prerendered page across the prerender -> interactive (WASM) seam.
// Without this, the WASM pass gets a fresh scoped ViewModel (Page == null), // Without this, the WASM pass gets a fresh scoped ViewModel (Page == null),
// re-renders the skeleton, re-fetches, and replaces the gallery DOM a few // re-renders the skeleton, re-fetches, and replaces the gallery DOM a few
@@ -31,7 +43,11 @@ public partial class TracksView : ComponentBase, IDisposable
// restore on the interactive pass, and only fetch on a miss. // restore on the interactive pass, and only fetch on a miss.
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks); _persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks);
if (PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored) && restored is not null) // The prerendered page is always unfiltered. When the URL carries filter params, that
// restored page is wrong for this view — skip the restore and fetch with the filter.
if (!ViewModel.HasActiveFilter
&& PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored)
&& restored is not null)
{ {
ViewModel.Page = restored; ViewModel.Page = restored;
ViewModel.PageNumber = restored.Page; ViewModel.PageNumber = restored.Page;
@@ -63,7 +79,9 @@ public partial class TracksView : ComponentBase, IDisposable
private Task PersistTracks() private Task PersistTracks()
{ {
if (ViewModel.Page is not null) // Only persist the unfiltered page. A filtered page restored onto a later plain /tracks
// visit would show the wrong results, so a filtered render leaves the cache untouched.
if (ViewModel.Page is not null && !ViewModel.HasActiveFilter)
{ {
PersistentState.PersistAsJson(PersistKey, ViewModel.Page); PersistentState.PersistAsJson(PersistKey, ViewModel.Page);
} }
@@ -72,7 +90,9 @@ public partial class TracksView : ComponentBase, IDisposable
private async Task SetPage(int newPage) private async Task SetPage(int newPage)
{ {
var result = await ViewModel.TrackData.GetPage(newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending); var result = await ViewModel.TrackData.GetPage(
newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending,
ViewModel.SearchText, ViewModel.FilterAlbum, ViewModel.FilterGenre);
if (result is { Success: true, Value: PagedResult<TrackDto> pageResult }) if (result is { Success: true, Value: PagedResult<TrackDto> pageResult })
{ {
@@ -81,6 +101,34 @@ public partial class TracksView : ComponentBase, IDisposable
} }
} }
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
// burst reaches here. Resets to page 1 since the result set changes, then re-fetches with the
// active filter (search + any album/genre pill compose).
private async Task OnSearchInput(string? value)
{
ViewModel.SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
ViewModel.PageNumber = 1;
await SetPage(1);
StateHasChanged();
}
// Clears the album/genre pill and returns to the unfiltered gallery. Updates the URL (drops the
// query param) and re-fetches in place. SearchText is intentionally left intact — the pill only
// represents FilterAlbum/FilterGenre, not free-text search, so clearing it must not discard an
// active search term. Blazor reuses the component on a same-route query change and does not
// re-run OnInitializedAsync, so the state reset + refetch happen here explicitly rather than
// relying on re-init.
private async Task ClearFilter()
{
ViewModel.FilterAlbum = null;
ViewModel.FilterGenre = null;
ViewModel.PageNumber = 1;
Navigation.NavigateTo("/tracks");
await SetPage(1);
StateHasChanged();
}
private async Task PlayTrack(TrackDto track) private async Task PlayTrack(TrackDto track)
{ {
// Resume the current track if it's merely paused; otherwise stream the new selection. // Resume the current track if it's merely paused; otherwise stream the new selection.
@@ -24,3 +24,22 @@
justify-content: flex-end; justify-content: flex-end;
padding: 0 0 12px 0; padding: 0 0 12px 0;
} }
.tracks-search-row {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
}
/* tracks-search-field rides on MudTextField, whose root is a child Razor component element.
Blazor isolation does not stamp the scope attribute there, so ::deep is required. */
::deep .tracks-search-field {
max-width: 420px;
width: 100%;
}
.tracks-filter-pills {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
}
@@ -16,7 +16,16 @@ public interface ITrackDataService
int pageNumber, int pageNumber,
int pageSize, int pageSize,
string? sortColumn = null, string? sortColumn = null,
bool sortDescending = false); bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null);
/// <summary>Distinct non-null albums with track counts and a representative cover key.</summary>
Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums();
/// <summary>Distinct non-null genres with track counts.</summary>
Task<ApiResult<List<GenreSummaryDto>>> GetGenres();
Task<ApiResult<TrackDto>> GetTrack(string trackId); Task<ApiResult<TrackDto>> GetTrack(string trackId);
@@ -23,8 +23,17 @@ public class TrackClientDataService : ITrackDataService
int pageNumber, int pageNumber,
int pageSize, int pageSize,
string? sortColumn = null, string? sortColumn = null,
bool sortDescending = false) bool sortDescending = false,
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending); string? searchText = null,
string? album = null,
string? genre = null)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
public Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
=> _trackClient.GetAlbums();
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
=> _trackClient.GetGenres();
public Task<ApiResult<TrackDto>> GetTrack(string trackId) public Task<ApiResult<TrackDto>> GetTrack(string trackId)
=> _trackClient.GetTrack(trackId); => _trackClient.GetTrack(trackId);
@@ -26,6 +26,18 @@ public class TracksViewModel
public bool IsDescending { get; set; } = false; public bool IsDescending { get; set; } = false;
public PagedResult<TrackDto>? Page { get; set; } = null; public PagedResult<TrackDto>? Page { get; set; } = null;
// Active gallery filters. Null/empty means "no filter on this dimension". SearchText is the
// free-text query; FilterAlbum/FilterGenre are exact-match pills driven by the /albums and
// /genres pages via query-string navigation.
public string? SearchText { get; set; }
public string? FilterAlbum { get; set; }
public string? FilterGenre { get; set; }
public bool HasActiveFilter =>
!string.IsNullOrWhiteSpace(SearchText)
|| !string.IsNullOrWhiteSpace(FilterAlbum)
|| !string.IsNullOrWhiteSpace(FilterGenre);
public TracksViewModel(ITrackDataService trackData) public TracksViewModel(ITrackDataService trackData)
{ {
TrackData = trackData; TrackData = trackData;
@@ -22,18 +22,27 @@ public class TrackProxyController : ControllerBase
_logger = logger; _logger = logger;
} }
/// <summary>Proxies paged track metadata from DeepDrftAPI.</summary> /// <summary>Proxies paged track metadata from DeepDrftAPI, forwarding optional filter params.</summary>
[HttpGet("page")] [HttpGet("page")]
public async Task<ActionResult> GetPage( public async Task<ActionResult> GetPage(
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null, [FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false, [FromQuery] bool sortDescending = false,
[FromQuery] string? q = null,
[FromQuery] string? album = null,
[FromQuery] string? genre = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var query = $"api/track/page?page={page}&pageSize={pageSize}&sortDescending={sortDescending}"; var query = $"api/track/page?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
if (!string.IsNullOrWhiteSpace(sortColumn)) if (!string.IsNullOrWhiteSpace(sortColumn))
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}"; query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
if (!string.IsNullOrWhiteSpace(q))
query += $"&q={Uri.EscapeDataString(q)}";
if (!string.IsNullOrWhiteSpace(album))
query += $"&album={Uri.EscapeDataString(album)}";
if (!string.IsNullOrWhiteSpace(genre))
query += $"&genre={Uri.EscapeDataString(genre)}";
HttpResponseMessage upstream; HttpResponseMessage upstream;
try try
@@ -92,6 +101,70 @@ public class TrackProxyController : ControllerBase
} }
} }
/// <summary>
/// Proxies the distinct-albums browse list from DeepDrftAPI. Unauthenticated, same posture as
/// the paged listing. Small JSON, buffered and relayed. Literal segment, declared before the
/// parameterized "{trackId}" route so it is never treated as a trackId.
/// </summary>
[HttpGet("albums")]
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync("api/track/albums", HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/albums failed");
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/albums returned {Status}", (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
/// <summary>
/// Proxies the distinct-genres browse list from DeepDrftAPI. Unauthenticated, same posture as
/// the paged listing. Small JSON, buffered and relayed. Literal segment, declared before the
/// parameterized "{trackId}" route so it is never treated as a trackId.
/// </summary>
[HttpGet("genres")]
public async Task<ActionResult> GetGenres(CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync("api/track/genres", HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/genres failed");
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/genres returned {Status}", (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
/// <summary> /// <summary>
/// Proxies single-track metadata lookup by vault entry key from DeepDrftAPI. Unauthenticated, /// Proxies single-track metadata lookup by vault entry key from DeepDrftAPI. Unauthenticated,
/// same posture as the paged listing. Small JSON, so it is buffered and relayed; a 404 from /// same posture as the paged listing. Small JSON, so it is buffered and relayed; a 404 from
+2
View File
@@ -13,6 +13,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NUnit" Version="4.4.0" /> <PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2"> <PackageReference Include="NUnit.Analyzers" Version="4.11.2">
@@ -28,6 +29,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" /> <ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+205
View File
@@ -0,0 +1,205 @@
using Data.Data.Repositories;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Models.Common;
namespace DeepDrftTests;
/// <summary>
/// Query-shape tests for the Phase 2.2/2.3 filter and distinct-browse repository methods.
///
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That
/// covers exact-match equality, null passthrough, GroupBy/Count, and ordering — every predicate
/// in <see cref="TrackRepository.GetPagedFilteredAsync"/> except the free-text branch. That branch
/// uses <c>EF.Functions.ILike</c>, an Npgsql-only relational function with no in-memory translation,
/// so the SearchText case is a Postgres integration test gated on a DSN (see SearchText_*). It is
/// ignored when no test database is configured rather than asserted against a provider that never
/// runs the predicate.
/// </summary>
[TestFixture]
public class TrackFilterQueryTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown()
{
_context.Dispose();
}
private TrackRepository CreateRepository()
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
private static TrackEntity Track(
string name, string artist, string? album = null, string? genre = null, string? image = null)
=> new()
{
EntryKey = Guid.NewGuid().ToString("N"),
TrackName = name,
Artist = artist,
Album = album,
Genre = genre,
ImagePath = image,
};
private async Task SeedAsync(params TrackEntity[] tracks)
{
_context.Tracks.AddRange(tracks);
await _context.SaveChangesAsync();
}
private static PagingParameters<TrackEntity> DefaultPaging()
=> new() { Page = 1, PageSize = 20, OrderBy = t => t.Id, IsDescending = false };
// Case 2 — exact album match: returns only rows whose Album equals the filter value, and
// TotalCount reflects the filtered set, not the table.
[Test]
public async Task GetPagedFilteredAsync_WithExactAlbum_ReturnsOnlyThatAlbum()
{
await SeedAsync(
Track("One", "A", album: "Blue"),
Track("Two", "B", album: "Blue"),
Track("Three", "C", album: "Red"),
Track("Four", "D", album: null));
var repo = CreateRepository();
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Album = "Blue" });
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Two" }));
}
// Case 2b — exact genre match composes the same way as album.
[Test]
public async Task GetPagedFilteredAsync_WithExactGenre_ReturnsOnlyThatGenre()
{
await SeedAsync(
Track("One", "A", genre: "Techno"),
Track("Two", "B", genre: "House"),
Track("Three", "C", genre: "Techno"));
var repo = CreateRepository();
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Genre = "Techno" });
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Three" }));
}
// Case 3 — null filter is a passthrough: same items and count as the unfiltered base GetPagedAsync.
[Test]
public async Task GetPagedFilteredAsync_WithNullFilter_MatchesUnfilteredPagedQuery()
{
await SeedAsync(
Track("One", "A", album: "Blue"),
Track("Two", "B", album: "Red"),
Track("Three", "C"));
var repo = CreateRepository();
var baseline = await repo.GetPagedAsync(DefaultPaging());
var filtered = await repo.GetPagedFilteredAsync(DefaultPaging(), filter: null);
Assert.That(filtered.TotalCount, Is.EqualTo(baseline.TotalCount));
Assert.That(
filtered.Items.Select(t => t.Id),
Is.EqualTo(baseline.Items.Select(t => t.Id)).AsCollection);
}
// Case 4 — distinct albums: excludes null-album rows, counts per group, and takes the cover from
// the first track in the group that has a non-null ImagePath. Ordered by album ascending.
[Test]
public async Task GetDistinctAlbumsAsync_GroupsCountsAndPicksCover()
{
await SeedAsync(
Track("One", "A", album: "Zephyr", image: null),
Track("Two", "A", album: "Zephyr", image: "cover-z"),
Track("Three", "B", album: "Aria", image: "cover-a"),
Track("Four", "C", album: null, image: "ignored"));
var repo = CreateRepository();
var albums = await repo.GetDistinctAlbumsAsync();
Assert.That(albums.Select(a => a.Album), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection,
"albums sort ascending and the null-album track is excluded");
var zephyr = albums.Single(a => a.Album == "Zephyr");
Assert.That(zephyr.TrackCount, Is.EqualTo(2));
Assert.That(zephyr.CoverImageKey, Is.EqualTo("cover-z"),
"cover is the first non-null ImagePath in the group");
var aria = albums.Single(a => a.Album == "Aria");
Assert.That(aria.TrackCount, Is.EqualTo(1));
Assert.That(aria.CoverImageKey, Is.EqualTo("cover-a"));
}
// Case 5 — distinct genres: excludes null-genre rows, counts per group, ordered genre ascending.
[Test]
public async Task GetDistinctGenresAsync_GroupsCountsAndExcludesNull()
{
await SeedAsync(
Track("One", "A", genre: "Techno"),
Track("Two", "B", genre: "Ambient"),
Track("Three", "C", genre: "Techno"),
Track("Four", "D", genre: null));
var repo = CreateRepository();
var genres = await repo.GetDistinctGenresAsync();
Assert.That(genres.Select(g => g.Genre), Is.EqualTo(new[] { "Ambient", "Techno" }).AsCollection,
"genres sort ascending and the null-genre track is excluded");
Assert.That(genres.Single(g => g.Genre == "Techno").TrackCount, Is.EqualTo(2));
Assert.That(genres.Single(g => g.Genre == "Ambient").TrackCount, Is.EqualTo(1));
}
// Case 1 — free-text search across TrackName/Artist/Album, case-insensitive. EF.Functions.ILike
// is Npgsql-only and does not translate on the in-memory provider, so this runs only against a
// real Postgres database supplied via the DEEPDRFT_TEST_PG environment variable. Without it the
// test is ignored rather than asserted against a provider that cannot execute the predicate.
[Test]
public async Task GetPagedFilteredAsync_WithSearchText_MatchesNameArtistOrAlbumCaseInsensitive()
{
var dsn = Environment.GetEnvironmentVariable("DEEPDRFT_TEST_PG");
if (string.IsNullOrWhiteSpace(dsn))
Assert.Ignore("Set DEEPDRFT_TEST_PG to a Postgres connection string to run the ILike search test.");
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseNpgsql(dsn)
.Options;
await using var pg = new DeepDrftContext(options);
await pg.Database.EnsureCreatedAsync();
try
{
pg.Tracks.AddRange(
Track("Jazz Odyssey", "Spinal Tap", album: "Smell the Glove"),
Track("Quiet Storm", "jazzmin", album: "Nightfall"),
Track("Loud Noises", "Brick", album: "All JAZZ Hands"),
Track("Unrelated", "Nobody", album: "Silence"));
await pg.SaveChangesAsync();
var repo = new TrackRepository(
pg, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { SearchText = "jazz" });
Assert.That(result.Items.Select(t => t.TrackName),
Is.EquivalentTo(new[] { "Jazz Odyssey", "Quiet Storm", "Loud Noises" }),
"ILike matches 'jazz' case-insensitively in TrackName, Artist, or Album");
}
finally
{
await pg.Database.EnsureDeletedAsync();
}
}
}