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
// 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<ActionResult> 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<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)
// 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.