Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix #119
@@ -102,6 +102,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||||
|
// Browse indexes (#367). Genre filtering rides
|
||||||
|
// /library/albums?genre= rather than a path segment, because raw
|
||||||
|
// ID3 genres contain slashes ("Rock/Pop") that a path can't carry.
|
||||||
|
authed.Get("/library/genres", h.handleListGenres)
|
||||||
|
authed.Get("/library/years", h.handleListAlbumYears)
|
||||||
authed.Get("/library/sync", h.handleLibrarySync)
|
authed.Get("/library/sync", h.handleLibrarySync)
|
||||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
// /tracks/{id}/stream is mounted above with OptionalUser so
|
||||||
|
|||||||
+162
-15
@@ -1,42 +1,189 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Widest plausible bounds for an open-ended year filter. A missing year_from
|
||||||
|
// means "from the beginning" rather than "from year zero of the query", and
|
||||||
|
// likewise for year_to, so the caller can filter on one edge only.
|
||||||
|
const (
|
||||||
|
minBrowseYear = 0
|
||||||
|
maxBrowseYear = 9999
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errBadYear = errors.New("year_from and year_to must be integers")
|
||||||
|
errInvertedYearRange = errors.New("year_from must not be greater than year_to")
|
||||||
|
)
|
||||||
|
|
||||||
|
// yearFilter carries a parsed, validated inclusive year range. active is false
|
||||||
|
// when the request asked for no year filtering at all — distinct from a range
|
||||||
|
// that happens to cover everything, because the two take different code paths.
|
||||||
|
type yearFilter struct {
|
||||||
|
from int32
|
||||||
|
to int32
|
||||||
|
active bool
|
||||||
|
}
|
||||||
|
|
||||||
// handleListLibraryAlbums implements GET /api/library/albums. Mirrors
|
// handleListLibraryAlbums implements GET /api/library/albums. Mirrors
|
||||||
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on
|
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on
|
||||||
// the SPA infinite-scrolls against this endpoint via TanStack
|
// the SPA infinite-scrolls against this endpoint via TanStack
|
||||||
// createInfiniteQuery.
|
// createInfiniteQuery.
|
||||||
|
//
|
||||||
|
// Optional filters (#367): `genre` and `year_from`/`year_to`.
|
||||||
|
//
|
||||||
|
// Genre arrives as a QUERY parameter rather than a path segment on purpose.
|
||||||
|
// Raw ID3 genres routinely contain a slash — "Rock/Pop" is a real tag, and
|
||||||
|
// the one the task itself cites — which cannot survive a path segment: Go
|
||||||
|
// normalises %2F and the router would split the value into two segments.
|
||||||
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
|
||||||
limit, offset, err := parsePaging(r.URL.Query())
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := dbq.New(h.pool)
|
genre := strings.TrimSpace(r.URL.Query().Get("genre"))
|
||||||
rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{
|
years, err := parseYearFilter(r.URL.Query())
|
||||||
Limit: int32(limit), Offset: int32(offset),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list library albums", "err", err)
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if genre != "" && years.active {
|
||||||
|
// Refused rather than silently honouring one: the UI browses these as
|
||||||
|
// separate axes (a genres page, a year filter on the albums page), so
|
||||||
|
// the combination can only arrive from a caller that has misunderstood
|
||||||
|
// the contract — and quietly dropping half a filter would report a
|
||||||
|
// narrower result set than it actually returned.
|
||||||
|
writeErr(w, apierror.BadRequest("unsupported_filter_combination",
|
||||||
|
"genre and year filters cannot be combined"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
var (
|
||||||
|
items []AlbumRef
|
||||||
|
total int64
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case genre != "":
|
||||||
|
items, total, err = albumsByGenre(r.Context(), q, genre, limit, offset)
|
||||||
|
case years.active:
|
||||||
|
items, total, err = albumsByYear(r.Context(), q, years, limit, offset)
|
||||||
|
default:
|
||||||
|
items, total, err = albumsAlpha(r.Context(), q, limit, offset)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list library albums", "err", err, "genre", genre, "years", years.active)
|
||||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := q.CountAlbums(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("api: count albums", "err", err)
|
|
||||||
writeErr(w, apierror.InternalMsg("count failed", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items := make([]AlbumRef, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, Page[AlbumRef]{
|
writeJSON(w, http.StatusOK, Page[AlbumRef]{
|
||||||
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func albumsAlpha(
|
||||||
|
ctx context.Context, q *dbq.Queries, limit, offset int,
|
||||||
|
) ([]AlbumRef, int64, error) {
|
||||||
|
rows, err := q.ListAlbumsAlphaWithArtist(ctx, dbq.ListAlbumsAlphaWithArtistParams{
|
||||||
|
Limit: int32(limit), Offset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total, err := q.CountAlbums(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]AlbumRef, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func albumsByGenre(
|
||||||
|
ctx context.Context, q *dbq.Queries, genre string, limit, offset int,
|
||||||
|
) ([]AlbumRef, int64, error) {
|
||||||
|
rows, err := q.ListAlbumsByGenreWithArtist(ctx, dbq.ListAlbumsByGenreWithArtistParams{
|
||||||
|
Genre: genre, Lim: int32(limit), Off: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total, err := q.CountAlbumsByGenre(ctx, genre)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]AlbumRef, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func albumsByYear(
|
||||||
|
ctx context.Context, q *dbq.Queries, years yearFilter, limit, offset int,
|
||||||
|
) ([]AlbumRef, int64, error) {
|
||||||
|
rows, err := q.ListAlbumsByYearRangeWithArtist(ctx,
|
||||||
|
dbq.ListAlbumsByYearRangeWithArtistParams{
|
||||||
|
YearFrom: years.from, YearTo: years.to,
|
||||||
|
Lim: int32(limit), Off: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total, err := q.CountAlbumsByYearRange(ctx, dbq.CountAlbumsByYearRangeParams{
|
||||||
|
YearFrom: years.from, YearTo: years.to,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]AlbumRef, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYearFilter reads year_from / year_to. Either may be omitted, which
|
||||||
|
// leaves that edge open — filtering "everything before 1990" shouldn't
|
||||||
|
// require inventing a lower bound.
|
||||||
|
func parseYearFilter(raw url.Values) (yearFilter, error) {
|
||||||
|
fromRaw := strings.TrimSpace(raw.Get("year_from"))
|
||||||
|
toRaw := strings.TrimSpace(raw.Get("year_to"))
|
||||||
|
if fromRaw == "" && toRaw == "" {
|
||||||
|
return yearFilter{}, nil
|
||||||
|
}
|
||||||
|
f := yearFilter{from: minBrowseYear, to: maxBrowseYear, active: true}
|
||||||
|
if fromRaw != "" {
|
||||||
|
n, err := strconv.Atoi(fromRaw)
|
||||||
|
if err != nil {
|
||||||
|
return yearFilter{}, errBadYear
|
||||||
|
}
|
||||||
|
f.from = int32(n)
|
||||||
|
}
|
||||||
|
if toRaw != "" {
|
||||||
|
n, err := strconv.Atoi(toRaw)
|
||||||
|
if err != nil {
|
||||||
|
return yearFilter{}, errBadYear
|
||||||
|
}
|
||||||
|
f.to = int32(n)
|
||||||
|
}
|
||||||
|
if f.from > f.to {
|
||||||
|
// Rejected rather than swapped: silently reordering would return
|
||||||
|
// results for a range the caller didn't ask for, and an inverted
|
||||||
|
// range is far more likely a bug than an intent.
|
||||||
|
return yearFilter{}, errInvertedYearRange
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -475,6 +475,9 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
"/api/tracks/00000000-0000-0000-0000-000000000001",
|
"/api/tracks/00000000-0000-0000-0000-000000000001",
|
||||||
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
||||||
"/api/search?q=x",
|
"/api/search?q=x",
|
||||||
|
// Browse indexes (#367).
|
||||||
|
"/api/library/genres",
|
||||||
|
"/api/library/years",
|
||||||
}
|
}
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||||
|
|||||||
@@ -478,23 +478,40 @@ func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
||||||
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
||||||
FROM albums
|
FROM albums
|
||||||
JOIN tracks ON tracks.album_id = albums.id
|
WHERE EXISTS (
|
||||||
WHERE tracks.genre = $1
|
SELECT 1
|
||||||
ORDER BY albums.id, albums.sort_title
|
FROM tracks
|
||||||
LIMIT $2 OFFSET $3
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim($1::text)
|
||||||
|
)
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT $3 OFFSET $2
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListAlbumsByGenreParams struct {
|
type ListAlbumsByGenreParams struct {
|
||||||
Genre *string
|
Genre string
|
||||||
Limit int32
|
Off int32
|
||||||
Offset int32
|
Lim int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album "belongs to" a genre if any of its tracks carry that genre.
|
// Album "belongs to" a genre if any of its tracks carry that genre.
|
||||||
|
// Serves Subsonic getAlbumList?type=byGenre.
|
||||||
|
//
|
||||||
|
// Splits tracks.genre on [;,] as of #367. It previously compared the whole
|
||||||
|
// column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER
|
||||||
|
// "Rock" or "Pop" — a Subsonic client asking for a genre silently missed
|
||||||
|
// every multi-genre track. This also aligns the endpoint with
|
||||||
|
// recommendation.sql / discover.sql, which have always split, and with the
|
||||||
|
// genre browse index that #367 adds.
|
||||||
|
//
|
||||||
|
// EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
|
||||||
|
// (track, genre-fragment), so a join would multiply rows per album and lean
|
||||||
|
// on DISTINCT to undo it. EXISTS asks the question directly.
|
||||||
func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) {
|
func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) {
|
||||||
rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Limit, arg.Offset)
|
rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Off, arg.Lim)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: browse.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one
|
||||||
|
SELECT COUNT(*) FROM albums
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim($1::text)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Total for the paging envelope. EXISTS mirrors the list query exactly; a
|
||||||
|
// JOIN + DISTINCT here would count differently the moment an album has two
|
||||||
|
// tracks carrying the same genre.
|
||||||
|
func (q *Queries) CountAlbumsByGenre(ctx context.Context, genre string) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countAlbumsByGenre, genre)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const countAlbumsByYearRange = `-- name: CountAlbumsByYearRange :one
|
||||||
|
SELECT COUNT(*) FROM albums
|
||||||
|
WHERE release_date IS NOT NULL
|
||||||
|
AND EXTRACT(YEAR FROM release_date)::int
|
||||||
|
BETWEEN $1::int AND $2::int
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountAlbumsByYearRangeParams struct {
|
||||||
|
YearFrom int32
|
||||||
|
YearTo int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CountAlbumsByYearRange(ctx context.Context, arg CountAlbumsByYearRangeParams) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countAlbumsByYearRange, arg.YearFrom, arg.YearTo)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlbumYearsWithCount = `-- name: ListAlbumYearsWithCount :many
|
||||||
|
SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count
|
||||||
|
FROM albums
|
||||||
|
WHERE release_date IS NOT NULL
|
||||||
|
GROUP BY year
|
||||||
|
ORDER BY year DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAlbumYearsWithCountRow struct {
|
||||||
|
Year int32
|
||||||
|
AlbumCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Year browse index (#367). Only albums with a release_date appear — an
|
||||||
|
// album with no date isn't "year unknown" as a browsable bucket, it's absent
|
||||||
|
// from this axis, and the UI says so rather than inventing a 0 row.
|
||||||
|
// Newest first: recent releases are the likelier browse target.
|
||||||
|
func (q *Queries) ListAlbumYearsWithCount(ctx context.Context) ([]ListAlbumYearsWithCountRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumYearsWithCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAlbumYearsWithCountRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAlbumYearsWithCountRow
|
||||||
|
if err := rows.Scan(&i.Year, &i.AlbumCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlbumsByGenreWithArtist = `-- name: ListAlbumsByGenreWithArtist :many
|
||||||
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
|
FROM albums
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim($1::text)
|
||||||
|
)
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT $3 OFFSET $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAlbumsByGenreWithArtistParams struct {
|
||||||
|
Genre string
|
||||||
|
Off int32
|
||||||
|
Lim int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListAlbumsByGenreWithArtistRow struct {
|
||||||
|
Album Album
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums for one genre, joined with artist_name for the browse grid.
|
||||||
|
// An album belongs to a genre when ANY of its tracks carry it. Splits and
|
||||||
|
// trims identically to ListGenresWithCount — if the list is built by
|
||||||
|
// splitting and the detail matched exactly, every multi-genre track would
|
||||||
|
// produce a genre row that leads to an empty page.
|
||||||
|
func (q *Queries) ListAlbumsByGenreWithArtist(ctx context.Context, arg ListAlbumsByGenreWithArtistParams) ([]ListAlbumsByGenreWithArtistRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumsByGenreWithArtist, arg.Genre, arg.Off, arg.Lim)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAlbumsByGenreWithArtistRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAlbumsByGenreWithArtistRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Album.ID,
|
||||||
|
&i.Album.Title,
|
||||||
|
&i.Album.SortTitle,
|
||||||
|
&i.Album.ArtistID,
|
||||||
|
&i.Album.ReleaseDate,
|
||||||
|
&i.Album.Mbid,
|
||||||
|
&i.Album.CoverArtPath,
|
||||||
|
&i.Album.CreatedAt,
|
||||||
|
&i.Album.UpdatedAt,
|
||||||
|
&i.Album.CoverArtSource,
|
||||||
|
&i.Album.CoverArtSourcesVersion,
|
||||||
|
&i.ArtistName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlbumsByYearRangeWithArtist = `-- name: ListAlbumsByYearRangeWithArtist :many
|
||||||
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
|
FROM albums
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
WHERE albums.release_date IS NOT NULL
|
||||||
|
AND EXTRACT(YEAR FROM albums.release_date)::int
|
||||||
|
BETWEEN $1::int AND $2::int
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT $4 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAlbumsByYearRangeWithArtistParams struct {
|
||||||
|
YearFrom int32
|
||||||
|
YearTo int32
|
||||||
|
Off int32
|
||||||
|
Lim int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListAlbumsByYearRangeWithArtistRow struct {
|
||||||
|
Album Album
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums released within an inclusive year range, for the albums-page filter.
|
||||||
|
func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListAlbumsByYearRangeWithArtistParams) ([]ListAlbumsByYearRangeWithArtistRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumsByYearRangeWithArtist,
|
||||||
|
arg.YearFrom,
|
||||||
|
arg.YearTo,
|
||||||
|
arg.Off,
|
||||||
|
arg.Lim,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAlbumsByYearRangeWithArtistRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAlbumsByYearRangeWithArtistRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Album.ID,
|
||||||
|
&i.Album.Title,
|
||||||
|
&i.Album.SortTitle,
|
||||||
|
&i.Album.ArtistID,
|
||||||
|
&i.Album.ReleaseDate,
|
||||||
|
&i.Album.Mbid,
|
||||||
|
&i.Album.CoverArtPath,
|
||||||
|
&i.Album.CreatedAt,
|
||||||
|
&i.Album.UpdatedAt,
|
||||||
|
&i.Album.CoverArtSource,
|
||||||
|
&i.Album.CoverArtSourcesVersion,
|
||||||
|
&i.ArtistName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listGenresWithCount = `-- name: ListGenresWithCount :many
|
||||||
|
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE trim(g.genre) <> ''
|
||||||
|
GROUP BY trim(g.genre)
|
||||||
|
ORDER BY track_count DESC, trim(g.genre)
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListGenresWithCountRow struct {
|
||||||
|
Genre string
|
||||||
|
TrackCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre browse index (#367).
|
||||||
|
//
|
||||||
|
// Genres live inline on tracks.genre as a delimited string, so this splits on
|
||||||
|
// the same [;,] pattern already used by recommendation.sql and discover.sql —
|
||||||
|
// a track tagged "Rock;Pop" must count toward both, and diverging from the
|
||||||
|
// established pattern here would make the browse surface disagree with what
|
||||||
|
// the recommendation engine believes the library contains.
|
||||||
|
//
|
||||||
|
// trim() but deliberately NO lower(): trimming repairs an artifact of OUR
|
||||||
|
// splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre
|
||||||
|
// would be a bug), whereas case is what the tag actually says. Raw ID3 is
|
||||||
|
// exposed as-is for v1, so "Rock" and "rock" appear as separate rows.
|
||||||
|
//
|
||||||
|
// COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
|
||||||
|
// inflate its own row.
|
||||||
|
//
|
||||||
|
// Ordered by count first: raw ID3 data has a long tail of one-off junk tags,
|
||||||
|
// so alphabetical would bury the handful of genres an operator actually has a
|
||||||
|
// library's worth of. Name breaks ties for a stable order.
|
||||||
|
// Ordered by the expression, not the output alias: `ORDER BY genre` is
|
||||||
|
// ambiguous between the alias and tracks.genre, and sqlc rejects it.
|
||||||
|
func (q *Queries) ListGenresWithCount(ctx context.Context) ([]ListGenresWithCountRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listGenresWithCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListGenresWithCountRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListGenresWithCountRow
|
||||||
|
if err := rows.Scan(&i.Genre, &i.TrackCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -61,12 +61,29 @@ SELECT * FROM albums ORDER BY random() LIMIT $1;
|
|||||||
|
|
||||||
-- name: ListAlbumsByGenre :many
|
-- name: ListAlbumsByGenre :many
|
||||||
-- Album "belongs to" a genre if any of its tracks carry that genre.
|
-- Album "belongs to" a genre if any of its tracks carry that genre.
|
||||||
SELECT DISTINCT ON (albums.id) albums.*
|
-- Serves Subsonic getAlbumList?type=byGenre.
|
||||||
|
--
|
||||||
|
-- Splits tracks.genre on [;,] as of #367. It previously compared the whole
|
||||||
|
-- column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER
|
||||||
|
-- "Rock" or "Pop" — a Subsonic client asking for a genre silently missed
|
||||||
|
-- every multi-genre track. This also aligns the endpoint with
|
||||||
|
-- recommendation.sql / discover.sql, which have always split, and with the
|
||||||
|
-- genre browse index that #367 adds.
|
||||||
|
--
|
||||||
|
-- EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
|
||||||
|
-- (track, genre-fragment), so a join would multiply rows per album and lean
|
||||||
|
-- on DISTINCT to undo it. EXISTS asks the question directly.
|
||||||
|
SELECT albums.*
|
||||||
FROM albums
|
FROM albums
|
||||||
JOIN tracks ON tracks.album_id = albums.id
|
WHERE EXISTS (
|
||||||
WHERE tracks.genre = $1
|
SELECT 1
|
||||||
ORDER BY albums.id, albums.sort_title
|
FROM tracks
|
||||||
LIMIT $2 OFFSET $3;
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
|
||||||
|
)
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
||||||
|
|
||||||
-- name: SearchAlbums :many
|
-- name: SearchAlbums :many
|
||||||
SELECT * FROM albums
|
SELECT * FROM albums
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- name: ListGenresWithCount :many
|
||||||
|
-- Genre browse index (#367).
|
||||||
|
--
|
||||||
|
-- Genres live inline on tracks.genre as a delimited string, so this splits on
|
||||||
|
-- the same [;,] pattern already used by recommendation.sql and discover.sql —
|
||||||
|
-- a track tagged "Rock;Pop" must count toward both, and diverging from the
|
||||||
|
-- established pattern here would make the browse surface disagree with what
|
||||||
|
-- the recommendation engine believes the library contains.
|
||||||
|
--
|
||||||
|
-- trim() but deliberately NO lower(): trimming repairs an artifact of OUR
|
||||||
|
-- splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre
|
||||||
|
-- would be a bug), whereas case is what the tag actually says. Raw ID3 is
|
||||||
|
-- exposed as-is for v1, so "Rock" and "rock" appear as separate rows.
|
||||||
|
--
|
||||||
|
-- COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
|
||||||
|
-- inflate its own row.
|
||||||
|
--
|
||||||
|
-- Ordered by count first: raw ID3 data has a long tail of one-off junk tags,
|
||||||
|
-- so alphabetical would bury the handful of genres an operator actually has a
|
||||||
|
-- library's worth of. Name breaks ties for a stable order.
|
||||||
|
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE trim(g.genre) <> ''
|
||||||
|
GROUP BY trim(g.genre)
|
||||||
|
-- Ordered by the expression, not the output alias: `ORDER BY genre` is
|
||||||
|
-- ambiguous between the alias and tracks.genre, and sqlc rejects it.
|
||||||
|
ORDER BY track_count DESC, trim(g.genre);
|
||||||
|
|
||||||
|
-- name: ListAlbumsByGenreWithArtist :many
|
||||||
|
-- Albums for one genre, joined with artist_name for the browse grid.
|
||||||
|
-- An album belongs to a genre when ANY of its tracks carry it. Splits and
|
||||||
|
-- trims identically to ListGenresWithCount — if the list is built by
|
||||||
|
-- splitting and the detail matched exactly, every multi-genre track would
|
||||||
|
-- produce a genre row that leads to an empty page.
|
||||||
|
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||||
|
FROM albums
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
|
||||||
|
)
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
||||||
|
|
||||||
|
-- name: CountAlbumsByGenre :one
|
||||||
|
-- Total for the paging envelope. EXISTS mirrors the list query exactly; a
|
||||||
|
-- JOIN + DISTINCT here would count differently the moment an album has two
|
||||||
|
-- tracks carrying the same genre.
|
||||||
|
SELECT COUNT(*) FROM albums
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- name: ListAlbumYearsWithCount :many
|
||||||
|
-- Year browse index (#367). Only albums with a release_date appear — an
|
||||||
|
-- album with no date isn't "year unknown" as a browsable bucket, it's absent
|
||||||
|
-- from this axis, and the UI says so rather than inventing a 0 row.
|
||||||
|
-- Newest first: recent releases are the likelier browse target.
|
||||||
|
SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count
|
||||||
|
FROM albums
|
||||||
|
WHERE release_date IS NOT NULL
|
||||||
|
GROUP BY year
|
||||||
|
ORDER BY year DESC;
|
||||||
|
|
||||||
|
-- name: ListAlbumsByYearRangeWithArtist :many
|
||||||
|
-- Albums released within an inclusive year range, for the albums-page filter.
|
||||||
|
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||||
|
FROM albums
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
WHERE albums.release_date IS NOT NULL
|
||||||
|
AND EXTRACT(YEAR FROM albums.release_date)::int
|
||||||
|
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
||||||
|
|
||||||
|
-- name: CountAlbumsByYearRange :one
|
||||||
|
SELECT COUNT(*) FROM albums
|
||||||
|
WHERE release_date IS NOT NULL
|
||||||
|
AND EXTRACT(YEAR FROM release_date)::int
|
||||||
|
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int;
|
||||||
@@ -284,8 +284,12 @@ func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) {
|
|||||||
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre")
|
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Genre is a plain string as of #367 — the query now splits
|
||||||
|
// tracks.genre on [;,] instead of comparing the whole column, so a
|
||||||
|
// client asking for "Rock" also reaches tracks tagged "Rock;Pop".
|
||||||
|
// Previously those were unreachable from either of their genres.
|
||||||
albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{
|
albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{
|
||||||
Genre: &genre, Limit: int32(size), Offset: int32(offset),
|
Genre: genre, Lim: int32(size), Off: int32(offset),
|
||||||
})
|
})
|
||||||
case "recent", "frequent":
|
case "recent", "frequent":
|
||||||
// Play history lands in M2; return empty to keep clients happy.
|
// Play history lands in M2; return empty to keep clients happy.
|
||||||
|
|||||||
Reference in New Issue
Block a user