From 5cae83b9ed62d8ac873c730a422c62887aae28b6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 10 Jun 2026 10:54:56 -0400 Subject: [PATCH] feat: add search/album/genre filtering and /albums + /genres browse pages --- DeepDrftAPI/Controllers/TrackController.cs | 45 +++- DeepDrftData/ITrackService.cs | 9 +- DeepDrftData/Repositories/TrackRepository.cs | 90 ++++++++ DeepDrftData/TrackManager.cs | 36 ++- DeepDrftModels/DTOs/AlbumSummaryDto.cs | 14 ++ DeepDrftModels/DTOs/GenreSummaryDto.cs | 8 + DeepDrftModels/DTOs/TrackFilter.cs | 27 +++ DeepDrftPublic.Client/Clients/TrackClient.cs | 50 ++++- DeepDrftPublic.Client/Layout/Pages.cs | 8 +- DeepDrftPublic.Client/Pages/AlbumsView.razor | 63 ++++++ .../Pages/AlbumsView.razor.cs | 26 +++ .../Pages/AlbumsView.razor.css | 58 +++++ DeepDrftPublic.Client/Pages/GenresView.razor | 41 ++++ .../Pages/GenresView.razor.cs | 26 +++ .../Pages/GenresView.razor.css | 28 +++ DeepDrftPublic.Client/Pages/TracksView.razor | 35 +++ .../Pages/TracksView.razor.cs | 54 ++++- .../Pages/TracksView.razor.css | 19 ++ .../Services/ITrackDataService.cs | 11 +- .../Services/TrackClientDataService.cs | 13 +- .../ViewModels/TracksViewModel.cs | 12 + .../Controllers/TrackProxyController.cs | 75 ++++++- DeepDrftTests/DeepDrftTests.csproj | 2 + DeepDrftTests/TrackFilterQueryTests.cs | 205 ++++++++++++++++++ 24 files changed, 940 insertions(+), 15 deletions(-) create mode 100644 DeepDrftModels/DTOs/AlbumSummaryDto.cs create mode 100644 DeepDrftModels/DTOs/GenreSummaryDto.cs create mode 100644 DeepDrftModels/DTOs/TrackFilter.cs create mode 100644 DeepDrftPublic.Client/Pages/AlbumsView.razor create mode 100644 DeepDrftPublic.Client/Pages/AlbumsView.razor.cs create mode 100644 DeepDrftPublic.Client/Pages/AlbumsView.razor.css create mode 100644 DeepDrftPublic.Client/Pages/GenresView.razor create mode 100644 DeepDrftPublic.Client/Pages/GenresView.razor.cs create mode 100644 DeepDrftPublic.Client/Pages/GenresView.razor.css create mode 100644 DeepDrftTests/TrackFilterQueryTests.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index 63a5514..3b9617c 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -47,17 +47,24 @@ public class TrackController : ControllerBase // These are declared before the parameterized "{trackId}" / "{id:long}" actions so route // 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}. + // q/album/genre build an optional TrackFilter; all null → null passthrough (no filtering). [HttpGet("page")] public async Task GetPage( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortColumn = null, [FromQuery] bool sortDescending = false, + [FromQuery] string? q = null, + [FromQuery] string? album = null, + [FromQuery] string? genre = null, 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) { var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error"; @@ -68,6 +75,40 @@ public class TrackController : ControllerBase 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 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 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) // 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. diff --git a/DeepDrftData/ITrackService.cs b/DeepDrftData/ITrackService.cs index 2b30754..761af6a 100644 --- a/DeepDrftData/ITrackService.cs +++ b/DeepDrftData/ITrackService.cs @@ -20,7 +20,14 @@ public interface ITrackService /// Task> GetRandom(CancellationToken cancellationToken = default); Task>> GetAll(); - Task>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default); + Task>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default); + + /// Distinct non-null albums with track counts and a representative cover key, album-ascending. + Task>> GetDistinctAlbums(CancellationToken cancellationToken = default); + + /// Distinct non-null genres with track counts, genre-ascending. + Task>> GetDistinctGenres(CancellationToken cancellationToken = default); + Task> Create(TrackDto newTrack); Task> Update(TrackDto track); Task Delete(long id); diff --git a/DeepDrftData/Repositories/TrackRepository.cs b/DeepDrftData/Repositories/TrackRepository.cs index c5d452f..9fc5636 100644 --- a/DeepDrftData/Repositories/TrackRepository.cs +++ b/DeepDrftData/Repositories/TrackRepository.cs @@ -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 .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> GetPagedFilteredAsync( + PagingParameters paging, + TrackFilter? filter, + CancellationToken ct = default) + { + IQueryable 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 + { + 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> 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> 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 diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index 862f101..47eb4fc 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -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.From(page, page.Items.Select(TrackConverter.Convert)); return ResultContainer>.CreatePassResult(dtoPage); } @@ -127,6 +135,32 @@ public class TrackManager } } + public async Task>> GetDistinctAlbums(CancellationToken cancellationToken = default) + { + try + { + var albums = await Repository.GetDistinctAlbumsAsync(cancellationToken); + return ResultContainer>.CreatePassResult(albums); + } + catch (Exception e) + { + return ResultContainer>.CreateFailResult(e.Message); + } + } + + public async Task>> GetDistinctGenres(CancellationToken cancellationToken = default) + { + try + { + var genres = await Repository.GetDistinctGenresAsync(cancellationToken); + return ResultContainer>.CreatePassResult(genres); + } + catch (Exception e) + { + return ResultContainer>.CreateFailResult(e.Message); + } + } + public async Task> Create(TrackDto newTrack) { try diff --git a/DeepDrftModels/DTOs/AlbumSummaryDto.cs b/DeepDrftModels/DTOs/AlbumSummaryDto.cs new file mode 100644 index 0000000..92a7d42 --- /dev/null +++ b/DeepDrftModels/DTOs/AlbumSummaryDto.cs @@ -0,0 +1,14 @@ +namespace DeepDrftModels.DTOs; + +/// +/// One distinct album with its track count and a representative cover image key. Backs the +/// /albums browse grid. +/// +public class AlbumSummaryDto +{ + public required string Album { get; set; } + public int TrackCount { get; set; } + + /// ImagePath of the first track in the album that has one; null when none do. + public string? CoverImageKey { get; set; } +} diff --git a/DeepDrftModels/DTOs/GenreSummaryDto.cs b/DeepDrftModels/DTOs/GenreSummaryDto.cs new file mode 100644 index 0000000..e00be4f --- /dev/null +++ b/DeepDrftModels/DTOs/GenreSummaryDto.cs @@ -0,0 +1,8 @@ +namespace DeepDrftModels.DTOs; + +/// One distinct genre with its track count. Backs the /genres browse list. +public class GenreSummaryDto +{ + public required string Genre { get; set; } + public int TrackCount { get; set; } +} diff --git a/DeepDrftModels/DTOs/TrackFilter.cs b/DeepDrftModels/DTOs/TrackFilter.cs new file mode 100644 index 0000000..8c5b369 --- /dev/null +++ b/DeepDrftModels/DTOs/TrackFilter.cs @@ -0,0 +1,27 @@ +namespace DeepDrftModels.DTOs; + +/// +/// Cross-project track filter contract. Threaded alongside (never inside) the external +/// PagingParameters<T>, which cannot carry a where-clause. An instance with all +/// properties null is equivalent to no filter — see TrackFilter.IsEmpty. +/// +public class TrackFilter +{ + /// Free-text, case-insensitive LIKE across TrackName, Artist, and Album. + public string? SearchText { get; set; } + + /// Exact album match. + public string? Album { get; set; } + + /// Exact genre match. + public string? Genre { get; set; } + + /// + /// 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. + /// + public bool IsEmpty => + string.IsNullOrWhiteSpace(SearchText) + && string.IsNullOrWhiteSpace(Album) + && string.IsNullOrWhiteSpace(Genre); +} diff --git a/DeepDrftPublic.Client/Clients/TrackClient.cs b/DeepDrftPublic.Client/Clients/TrackClient.cs index f7317e3..245fce4 100644 --- a/DeepDrftPublic.Client/Clients/TrackClient.cs +++ b/DeepDrftPublic.Client/Clients/TrackClient.cs @@ -20,7 +20,10 @@ public class TrackClient int pageNumber, int pageSize, string? sortColumn = null, - bool sortDescending = false) + bool sortDescending = false, + string? searchText = null, + string? album = null, + string? genre = null) { var queryArgs = new Dictionary(){ ["page"] = pageNumber.ToString(), @@ -33,6 +36,15 @@ public class TrackClient if (sortDescending) 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(); var response = await _http.GetAsync($"api/track/page{query}"); @@ -77,6 +89,42 @@ public class TrackClient : ApiResult.CreateFailResult("Failed to deserialize response"); } + public async Task>> GetAlbums() + { + var response = await _http.GetAsync("api/track/albums"); + + if (!response.IsSuccessStatusCode) + return ApiResult>.CreateFailResult($"HTTP {(int)response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + var albums = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return albums is not null + ? ApiResult>.CreatePassResult(albums) + : ApiResult>.CreateFailResult("Failed to deserialize response"); + } + + public async Task>> GetGenres() + { + var response = await _http.GetAsync("api/track/genres"); + + if (!response.IsSuccessStatusCode) + return ApiResult>.CreateFailResult($"HTTP {(int)response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + var genres = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return genres is not null + ? ApiResult>.CreatePassResult(genres) + : ApiResult>.CreateFailResult("Failed to deserialize response"); + } + public async Task> GetTrack(string entryKey) { var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}"); diff --git a/DeepDrftPublic.Client/Layout/Pages.cs b/DeepDrftPublic.Client/Layout/Pages.cs index a00b966..7a82b70 100644 --- a/DeepDrftPublic.Client/Layout/Pages.cs +++ b/DeepDrftPublic.Client/Layout/Pages.cs @@ -13,9 +13,11 @@ public static class Pages { public static readonly List MenuPages = [ - 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 = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships + new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic }, + new() { Name = "Albums", Route = "/albums", Icon = Icons.Material.Filled.Album }, + 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 AllPages = diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor b/DeepDrftPublic.Client/Pages/AlbumsView.razor new file mode 100644 index 0000000..70f3da9 --- /dev/null +++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor @@ -0,0 +1,63 @@ +@page "/albums" + +DeepDrft Albums + +
+ + @if (_loading) + { + + @foreach (var _ in Enumerable.Range(0, 8)) + { + +
+ +
+
+ } +
+ } + else if (_albums.Count == 0) + { +
+ No albums yet +
+ } + else + { + + @foreach (var album in _albums) + { + +
+
+ @if (!string.IsNullOrEmpty(album.CoverImageKey)) + { +
+
+ } + else + { +
+ } + +
+ + @album.Album + + + @album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks") + +
+
+
+
+ } +
+ } +
+
diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor.cs b/DeepDrftPublic.Client/Pages/AlbumsView.razor.cs new file mode 100644 index 0000000..6406bfd --- /dev/null +++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor.cs @@ -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 _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)}"); +} diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor.css b/DeepDrftPublic.Client/Pages/AlbumsView.razor.css new file mode 100644 index 0000000..bc97c05 --- /dev/null +++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor.css @@ -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; +} diff --git a/DeepDrftPublic.Client/Pages/GenresView.razor b/DeepDrftPublic.Client/Pages/GenresView.razor new file mode 100644 index 0000000..988d6d6 --- /dev/null +++ b/DeepDrftPublic.Client/Pages/GenresView.razor @@ -0,0 +1,41 @@ +@page "/genres" + +DeepDrft Genres + +
+ + @if (_loading) + { +
+ @foreach (var _ in Enumerable.Range(0, 8)) + { + + } +
+ } + else if (_genres.Count == 0) + { +
+ No genres yet +
+ } + else + { + + @foreach (var genre in _genres) + { + +
+ @genre.Genre + + @genre.TrackCount @(genre.TrackCount == 1 ? "track" : "tracks") + +
+
+ } +
+ } +
+
diff --git a/DeepDrftPublic.Client/Pages/GenresView.razor.cs b/DeepDrftPublic.Client/Pages/GenresView.razor.cs new file mode 100644 index 0000000..1348d12 --- /dev/null +++ b/DeepDrftPublic.Client/Pages/GenresView.razor.cs @@ -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 _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)}"); +} diff --git a/DeepDrftPublic.Client/Pages/GenresView.razor.css b/DeepDrftPublic.Client/Pages/GenresView.razor.css new file mode 100644 index 0000000..0279f9a --- /dev/null +++ b/DeepDrftPublic.Client/Pages/GenresView.razor.css @@ -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; +} diff --git a/DeepDrftPublic.Client/Pages/TracksView.razor b/DeepDrftPublic.Client/Pages/TracksView.razor index 401cc71..50825b8 100644 --- a/DeepDrftPublic.Client/Pages/TracksView.razor +++ b/DeepDrftPublic.Client/Pages/TracksView.razor @@ -5,6 +5,41 @@
+ @* 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) + { +
+ +
+ + @if (ViewModel.FilterAlbum is not null || ViewModel.FilterGenre is not null) + { +
+ + @(ViewModel.FilterAlbum is not null + ? $"Album: {ViewModel.FilterAlbum}" + : $"Genre: {ViewModel.FilterGenre}") + +
+ } + } + @if (ViewModel.Page != null) {
diff --git a/DeepDrftPublic.Client/Pages/TracksView.razor.cs b/DeepDrftPublic.Client/Pages/TracksView.razor.cs index 97afe4c..bd5d42a 100644 --- a/DeepDrftPublic.Client/Pages/TracksView.razor.cs +++ b/DeepDrftPublic.Client/Pages/TracksView.razor.cs @@ -13,8 +13,15 @@ public partial class TracksView : ComponentBase, IDisposable [Inject] public required TracksViewModel ViewModel { get; set; } [Inject] public required PersistentComponentState PersistentState { get; set; } + [Inject] public required NavigationManager Navigation { 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 PersistingComponentStateSubscription _persistingSubscription; @@ -23,6 +30,11 @@ public partial class TracksView : ComponentBase, IDisposable 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. // 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 @@ -31,7 +43,11 @@ public partial class TracksView : ComponentBase, IDisposable // restore on the interactive pass, and only fetch on a miss. _persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks); - if (PersistentState.TryTakeFromJson>(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>(PersistKey, out var restored) + && restored is not null) { ViewModel.Page = restored; ViewModel.PageNumber = restored.Page; @@ -63,7 +79,9 @@ public partial class TracksView : ComponentBase, IDisposable 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); } @@ -72,7 +90,9 @@ public partial class TracksView : ComponentBase, IDisposable 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 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) { // Resume the current track if it's merely paused; otherwise stream the new selection. diff --git a/DeepDrftPublic.Client/Pages/TracksView.razor.css b/DeepDrftPublic.Client/Pages/TracksView.razor.css index 8a57c43..1ef4490 100644 --- a/DeepDrftPublic.Client/Pages/TracksView.razor.css +++ b/DeepDrftPublic.Client/Pages/TracksView.razor.css @@ -24,3 +24,22 @@ justify-content: flex-end; 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; +} diff --git a/DeepDrftPublic.Client/Services/ITrackDataService.cs b/DeepDrftPublic.Client/Services/ITrackDataService.cs index 01c94f3..aa336c4 100644 --- a/DeepDrftPublic.Client/Services/ITrackDataService.cs +++ b/DeepDrftPublic.Client/Services/ITrackDataService.cs @@ -16,7 +16,16 @@ public interface ITrackDataService int pageNumber, int pageSize, string? sortColumn = null, - bool sortDescending = false); + bool sortDescending = false, + string? searchText = null, + string? album = null, + string? genre = null); + + /// Distinct non-null albums with track counts and a representative cover key. + Task>> GetAlbums(); + + /// Distinct non-null genres with track counts. + Task>> GetGenres(); Task> GetTrack(string trackId); diff --git a/DeepDrftPublic.Client/Services/TrackClientDataService.cs b/DeepDrftPublic.Client/Services/TrackClientDataService.cs index 8d0bb55..f3f5fab 100644 --- a/DeepDrftPublic.Client/Services/TrackClientDataService.cs +++ b/DeepDrftPublic.Client/Services/TrackClientDataService.cs @@ -23,8 +23,17 @@ public class TrackClientDataService : ITrackDataService int pageNumber, int pageSize, string? sortColumn = null, - bool sortDescending = false) - => _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending); + bool sortDescending = false, + string? searchText = null, + string? album = null, + string? genre = null) + => _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre); + + public Task>> GetAlbums() + => _trackClient.GetAlbums(); + + public Task>> GetGenres() + => _trackClient.GetGenres(); public Task> GetTrack(string trackId) => _trackClient.GetTrack(trackId); diff --git a/DeepDrftPublic.Client/ViewModels/TracksViewModel.cs b/DeepDrftPublic.Client/ViewModels/TracksViewModel.cs index a53ca31..85507d2 100644 --- a/DeepDrftPublic.Client/ViewModels/TracksViewModel.cs +++ b/DeepDrftPublic.Client/ViewModels/TracksViewModel.cs @@ -26,6 +26,18 @@ public class TracksViewModel public bool IsDescending { get; set; } = false; public PagedResult? 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) { TrackData = trackData; diff --git a/DeepDrftPublic/Controllers/TrackProxyController.cs b/DeepDrftPublic/Controllers/TrackProxyController.cs index 3128a17..010fb9d 100644 --- a/DeepDrftPublic/Controllers/TrackProxyController.cs +++ b/DeepDrftPublic/Controllers/TrackProxyController.cs @@ -22,18 +22,27 @@ public class TrackProxyController : ControllerBase _logger = logger; } - /// Proxies paged track metadata from DeepDrftAPI. + /// Proxies paged track metadata from DeepDrftAPI, forwarding optional filter params. [HttpGet("page")] public async Task GetPage( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? sortColumn = null, [FromQuery] bool sortDescending = false, + [FromQuery] string? q = null, + [FromQuery] string? album = null, + [FromQuery] string? genre = null, CancellationToken ct = default) { var query = $"api/track/page?page={page}&pageSize={pageSize}&sortDescending={sortDescending}"; if (!string.IsNullOrWhiteSpace(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; try @@ -92,6 +101,70 @@ public class TrackProxyController : ControllerBase } } + /// + /// 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. + /// + [HttpGet("albums")] + public async Task 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"); + } + } + + /// + /// 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. + /// + [HttpGet("genres")] + public async Task 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"); + } + } + /// /// 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 diff --git a/DeepDrftTests/DeepDrftTests.csproj b/DeepDrftTests/DeepDrftTests.csproj index 1e11c7d..a3b1407 100644 --- a/DeepDrftTests/DeepDrftTests.csproj +++ b/DeepDrftTests/DeepDrftTests.csproj @@ -13,6 +13,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -28,6 +29,7 @@ + diff --git a/DeepDrftTests/TrackFilterQueryTests.cs b/DeepDrftTests/TrackFilterQueryTests.cs new file mode 100644 index 0000000..ee35de3 --- /dev/null +++ b/DeepDrftTests/TrackFilterQueryTests.cs @@ -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; + +/// +/// 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 except the free-text branch. That branch +/// uses EF.Functions.ILike, 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. +/// +[TestFixture] +public class TrackFilterQueryTests +{ + private DeepDrftContext _context = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new DeepDrftContext(options); + } + + [TearDown] + public void TearDown() + { + _context.Dispose(); + } + + private TrackRepository CreateRepository() + => new(_context, NullLogger>.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 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() + .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>.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(); + } + } +}