Server half of #367. Web UI follows. Genres are exposed AS-IS per the operator: split on the delimiter, trimmed, but no case folding and no synonym mapping. So "Rock" and "rock" appear as separate rows, as does "Rock/Pop" alongside "Rock" and "Pop". The raw spread has to be visible before anyone can judge whether it needs normalising, and the alternative is a mapping table to invent and then maintain. Trimming is not an exception to that. Splitting "Rock; Pop" yields " Pop", and showing that as a genre distinct from "Pop" would be a bug in OUR splitting, not fidelity to the operator's tags. ## The correctness trap this had to avoid ListAlbumsByGenre compared tracks.genre verbatim, while recommendation.sql and discover.sql have always split it on [;,]. Building the browse index by splitting while matching exactly would have listed genres whose pages are empty — every multi-genre track unreachable from either of its genres. So ListAlbumsByGenre now splits too. That also fixes Subsonic getAlbumList?type=byGenre, its only caller, which silently missed every multi-genre track. Its Genre param went *string → string as a result. EXISTS rather than JOIN + DISTINCT ON throughout: the lateral split emits one row per (track, fragment), so a join multiplies rows per album and needs DISTINCT to undo itself. EXISTS asks the question directly, and the count query then matches the list query by construction rather than by coincidence. ## Genre is a query parameter, not a path segment Because "Rock/Pop" is a real ID3 tag — the one the task itself cites — and a slash cannot survive a path segment: Go normalises %2F and the router would split the value in two. So filtering rides GET /api/library/albums?genre=, which also reuses the existing paged album surface instead of adding a parallel one. Endpoints: GET /api/library/genres unpaged index + track counts GET /api/library/years unpaged index + album counts GET /api/library/albums?genre= filtered page GET /api/library/albums?year_from=&year_to= filtered page, either edge open The indexes are unpaged deliberately: a client needs the whole set to render a browsable picker, and paging would let it show only a prefix of an ordering the user didn't choose. Two refusals rather than guesses: genre+year together is a 400 (the UI browses them as separate axes, and quietly dropping half a filter would report a narrower result than it returned), and an inverted year range is a 400 rather than being silently swapped. Undated albums are absent from the year axis rather than bucketed under 0 — "unknown" is not a year, and a 0 row would sort to one end of a chronological list looking like data. Tests: parseYearFilter is pure and runs in the fast lane. The integration tests assert the thing that would otherwise be silently broken — that a "Rock;Pop" track is reachable from BOTH genres, that "Rock/Pop" survives as a filter value, that fragment whitespace is trimmed, and that undated albums stay out of every year range. Reused the existing seedAlbum/seedTrackWithGenre fixtures, which already took exactly the year and genre arguments needed.
326 lines
9.9 KiB
Go
326 lines
9.9 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
)
|
|
|
|
// parseYearFilter is pure, so this runs in the fast lane rather than waiting
|
|
// on the integration job.
|
|
func TestParseYearFilter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
wantActive bool
|
|
wantFrom int32
|
|
wantTo int32
|
|
wantErr error
|
|
}{
|
|
{name: "no params means no filtering", query: "", wantActive: false},
|
|
{
|
|
name: "both bounds", query: "year_from=1990&year_to=1999",
|
|
wantActive: true, wantFrom: 1990, wantTo: 1999,
|
|
},
|
|
{
|
|
// "everything from 2000 onward" shouldn't require the caller to
|
|
// invent an upper bound.
|
|
name: "from only leaves the upper edge open", query: "year_from=2000",
|
|
wantActive: true, wantFrom: 2000, wantTo: maxBrowseYear,
|
|
},
|
|
{
|
|
name: "to only leaves the lower edge open", query: "year_to=1979",
|
|
wantActive: true, wantFrom: minBrowseYear, wantTo: 1979,
|
|
},
|
|
{
|
|
name: "a single year is a degenerate range", query: "year_from=1985&year_to=1985",
|
|
wantActive: true, wantFrom: 1985, wantTo: 1985,
|
|
},
|
|
{name: "non-numeric from", query: "year_from=nineteen", wantErr: errBadYear},
|
|
{name: "non-numeric to", query: "year_to=x", wantErr: errBadYear},
|
|
{
|
|
// Rejected, not silently swapped — reordering would answer a
|
|
// question the caller didn't ask.
|
|
name: "inverted range", query: "year_from=2000&year_to=1990",
|
|
wantErr: errInvertedYearRange,
|
|
},
|
|
{
|
|
name: "whitespace-only values are treated as absent",
|
|
query: "year_from=%20&year_to=%20", wantActive: false,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
raw, err := url.ParseQuery(tc.query)
|
|
if err != nil {
|
|
t.Fatalf("ParseQuery: %v", err)
|
|
}
|
|
got, gotErr := parseYearFilter(raw)
|
|
if tc.wantErr != nil {
|
|
if gotErr != tc.wantErr {
|
|
t.Fatalf("error = %v, want %v", gotErr, tc.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if gotErr != nil {
|
|
t.Fatalf("unexpected error: %v", gotErr)
|
|
}
|
|
if got.active != tc.wantActive {
|
|
t.Errorf("active = %v, want %v", got.active, tc.wantActive)
|
|
}
|
|
if tc.wantActive && (got.from != tc.wantFrom || got.to != tc.wantTo) {
|
|
t.Errorf("range = [%d,%d], want [%d,%d]",
|
|
got.from, got.to, tc.wantFrom, tc.wantTo)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The crux of #367: a track tagged "Rock;Pop" must be reachable from BOTH
|
|
// genres. An exact-string match — which is what ListAlbumsByGenre did before
|
|
// this task — makes every multi-genre track invisible from either of its
|
|
// genres, so the index would list a genre whose page is empty.
|
|
func TestListGenres_SplitsMultiGenreTags(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
artist := seedArtist(t, pool, "Genre Splitter")
|
|
album := seedAlbum(t, pool, artist.ID, "Split Album", 1995)
|
|
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Both Genres", 1, 200000, "Rock;Pop")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleListGenres(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
var got []genreCount
|
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
counts := map[string]int{}
|
|
for _, g := range got {
|
|
counts[g.Genre] = g.TrackCount
|
|
}
|
|
for _, want := range []string{"Rock", "Pop"} {
|
|
if counts[want] < 1 {
|
|
t.Errorf("genre %q missing from index (got %v)", want, counts)
|
|
}
|
|
}
|
|
// The undivided string must NOT appear as its own genre.
|
|
if _, ok := counts["Rock;Pop"]; ok {
|
|
t.Error(`"Rock;Pop" surfaced as a single genre — the split didn't happen`)
|
|
}
|
|
}
|
|
|
|
// Splitting produces leading spaces on every fragment after the first, and
|
|
// showing " Pop" as a genre distinct from "Pop" would be a bug. Trimming is a
|
|
// repair for our own splitting, not normalisation of the operator's tags.
|
|
func TestListGenres_TrimsFragmentWhitespace(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
artist := seedArtist(t, pool, "Spacey Tags")
|
|
album := seedAlbum(t, pool, artist.ID, "Spacey Album", 2001)
|
|
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Spaced", 1, 200000, "Jazz; Blues ;")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleListGenres(w, req)
|
|
|
|
var got []genreCount
|
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, g := range got {
|
|
seen[g.Genre] = true
|
|
if g.Genre == "" {
|
|
t.Error("empty genre in index — a trailing delimiter leaked through")
|
|
}
|
|
}
|
|
for _, want := range []string{"Jazz", "Blues"} {
|
|
if !seen[want] {
|
|
t.Errorf("genre %q missing (got %v)", want, keysOf(seen))
|
|
}
|
|
}
|
|
for _, unwanted := range []string{" Blues", "Blues ", " Blues "} {
|
|
if seen[unwanted] {
|
|
t.Errorf("untrimmed genre %q present", unwanted)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Genre filtering must agree with the index: every genre the index lists has
|
|
// to lead to a non-empty page, which is exactly what the old exact-match
|
|
// query could not guarantee.
|
|
func TestListLibraryAlbums_GenreFilterReachesMultiGenreTracks(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
artist := seedArtist(t, pool, "Reachable")
|
|
album := seedAlbum(t, pool, artist.ID, "Reachable Album", 1998)
|
|
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Multi", 1, 200000, "Rock;Pop")
|
|
|
|
for _, genre := range []string{"Rock", "Pop"} {
|
|
t.Run(genre, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/api/library/albums?genre="+url.QueryEscape(genre), nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleListLibraryAlbums(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
var page Page[AlbumRef]
|
|
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if page.Total < 1 {
|
|
t.Fatalf("total = %d, want >=1 — genre %q led to an empty page",
|
|
page.Total, genre)
|
|
}
|
|
found := false
|
|
for _, a := range page.Items {
|
|
if a.Title == "Reachable Album" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("seeded album absent from genre %q results", genre)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A genre containing a slash is why filtering is a query parameter rather
|
|
// than a path segment — "Rock/Pop" cannot survive a path.
|
|
func TestListLibraryAlbums_GenreWithSlashSurvives(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
artist := seedArtist(t, pool, "Slashed")
|
|
album := seedAlbum(t, pool, artist.ID, "Slashed Album", 2003)
|
|
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Slashy", 1, 200000, "Rock/Pop")
|
|
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/api/library/albums?genre="+url.QueryEscape("Rock/Pop"), nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleListLibraryAlbums(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
var page Page[AlbumRef]
|
|
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if page.Total < 1 {
|
|
t.Errorf(`total = %d, want >=1 for genre "Rock/Pop"`, page.Total)
|
|
}
|
|
}
|
|
|
|
func TestListLibraryAlbums_YearRangeFilter(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
artist := seedArtist(t, pool, "Chronology")
|
|
seedAlbum(t, pool, artist.ID, "Old Record", 1972)
|
|
seedAlbum(t, pool, artist.ID, "Middle Record", 1995)
|
|
seedAlbum(t, pool, artist.ID, "New Record", 2020)
|
|
// An undated album must not appear in ANY year range.
|
|
seedAlbum(t, pool, artist.ID, "Undated Record", 0)
|
|
|
|
titles := func(query string) map[string]bool {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/library/albums?"+query, nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleListLibraryAlbums(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d for %q, want 200", w.Code, query)
|
|
}
|
|
var page Page[AlbumRef]
|
|
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
out := map[string]bool{}
|
|
for _, a := range page.Items {
|
|
out[a.Title] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
got := titles("year_from=1990&year_to=2000&limit=200")
|
|
if !got["Middle Record"] {
|
|
t.Error("Middle Record (1995) missing from 1990-2000")
|
|
}
|
|
for _, absent := range []string{"Old Record", "New Record", "Undated Record"} {
|
|
if got[absent] {
|
|
t.Errorf("%s present in 1990-2000 range", absent)
|
|
}
|
|
}
|
|
|
|
// Open upper edge.
|
|
got = titles("year_from=1990&limit=200")
|
|
if !got["Middle Record"] || !got["New Record"] {
|
|
t.Error("open-ended year_from should include 1995 and 2020")
|
|
}
|
|
if got["Old Record"] {
|
|
t.Error("Old Record (1972) present in year_from=1990")
|
|
}
|
|
if got["Undated Record"] {
|
|
t.Error("undated album present in an open-ended range")
|
|
}
|
|
}
|
|
|
|
func TestListLibraryAlbums_RejectsGenreAndYearTogether(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
req := httptest.NewRequest(http.MethodGet,
|
|
"/api/library/albums?genre=Rock&year_from=1990", nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleListLibraryAlbums(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 for combined filters", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListAlbumYears_ExcludesUndatedAlbums(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
artist := seedArtist(t, pool, "Years Only")
|
|
seedAlbum(t, pool, artist.ID, "Dated One", 1984)
|
|
seedAlbum(t, pool, artist.ID, "No Date", 0)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/library/years", nil)
|
|
w := httptest.NewRecorder()
|
|
h.handleListAlbumYears(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
var got []yearCount
|
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
found1984 := false
|
|
for _, y := range got {
|
|
if y.Year == 1984 {
|
|
found1984 = true
|
|
}
|
|
if y.Year == 0 {
|
|
t.Error("year 0 present — undated albums leaked into the index")
|
|
}
|
|
}
|
|
if !found1984 {
|
|
t.Error("1984 missing from the year index")
|
|
}
|
|
// Newest-first ordering, so a picker reads chronologically without the
|
|
// client re-sorting.
|
|
for i := 1; i < len(got); i++ {
|
|
if got[i-1].Year < got[i].Year {
|
|
t.Errorf("years not descending at %d: %d then %d", i, got[i-1].Year, got[i].Year)
|
|
}
|
|
}
|
|
}
|
|
|
|
func keysOf(m map[string]bool) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|