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.
66 lines
2.3 KiB
Go
66 lines
2.3 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// genreCount is one row of the genre browse index (#367).
|
|
//
|
|
// Genres are the raw ID3 strings, split on [;,] but otherwise untouched — no
|
|
// case folding and no synonym mapping. So "Rock" and "rock" can both appear,
|
|
// as can "Rock/Pop" alongside "Rock" and "Pop". That's deliberate for v1: the
|
|
// alternative is a normalisation table to invent and maintain, and the raw
|
|
// spread has to be visible before anyone can judge whether it's a problem.
|
|
type genreCount struct {
|
|
Genre string `json:"genre"`
|
|
TrackCount int `json:"track_count"`
|
|
}
|
|
|
|
// yearCount is one row of the year browse index.
|
|
type yearCount struct {
|
|
Year int `json:"year"`
|
|
AlbumCount int `json:"album_count"`
|
|
}
|
|
|
|
// handleListGenres implements GET /api/library/genres.
|
|
//
|
|
// Unpaged on purpose. Even a messy library yields hundreds of distinct tag
|
|
// strings, not thousands, and the client needs the whole set at once to render
|
|
// a browsable index — paging it would mean the UI could only ever show a
|
|
// prefix of an ordering the user didn't choose.
|
|
func (h *handlers) handleListGenres(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := dbq.New(h.pool).ListGenresWithCount(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("api: list genres", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
out := make([]genreCount, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, genreCount{Genre: row.Genre, TrackCount: int(row.TrackCount)})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// handleListAlbumYears implements GET /api/library/years.
|
|
//
|
|
// Albums with no release_date are absent rather than bucketed under 0 — "year
|
|
// unknown" isn't a year, and inventing a row for it would put a fake entry at
|
|
// one end of a chronological list.
|
|
func (h *handlers) handleListAlbumYears(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := dbq.New(h.pool).ListAlbumYearsWithCount(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("api: list album years", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
out := make([]yearCount, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, yearCount{Year: int(row.Year), AlbumCount: int(row.AlbumCount)})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|