Files
minstrel/internal/subsonic/browse.go
T
bvandeusen 1126bfcf78
test-go / test (push) Failing after 46s
test-go / integration (push) Successful in 5m0s
feat(library): genre + year browse queries and endpoints — #367
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.
2026-08-05 13:22:30 -04:00

456 lines
14 KiB
Go

package subsonic
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// browseHandlers carries the shared pgxpool used by every browse endpoint.
// Handlers are methods so Mount can register them without package globals.
type browseHandlers struct {
pool *pgxpool.Pool
}
// Subsonic's "ignoredArticles" is advisory — it tells clients which leading
// words to strip when computing their own index buckets. The server is
// responsible for storing sort_name in the desired form; this attr exists
// for backwards compatibility with clients that regroup client-side.
const ignoredArticles = "The El La Los Las Le Les"
// getMusicFolders always returns a single folder. Minstrel has no multi-library
// concept in M1; clients use the folder id as an opaque cookie so we return 1.
func (b *browseHandlers) getMusicFolders(w http.ResponseWriter, r *http.Request) {
Write(w, r, MusicFoldersResponse{
Envelope: NewEnvelope("ok"),
MusicFolders: MusicFoldersContainer{
MusicFolders: []MusicFolder{{ID: 1, Name: "Music"}},
},
})
}
// getIndexes is the legacy namespace — identical shape to getArtists but with
// a lastModified attr. We report the max(updated_at) across artists so clients
// can short-circuit re-sync when nothing has changed.
func (b *browseHandlers) getIndexes(w http.ResponseWriter, r *http.Request) {
q := dbq.New(b.pool)
indexes, lastMod, err := b.buildArtistIndexes(r.Context(), q)
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to load artists")
return
}
Write(w, r, IndexesResponse{
Envelope: NewEnvelope("ok"),
Indexes: IndexesContainer{
LastModified: lastMod,
IgnoredArticles: ignoredArticles,
Indexes: indexes,
},
})
}
// getArtists is the ID3 equivalent of getIndexes. Same bucketing, no
// lastModified. Most modern clients prefer this endpoint.
func (b *browseHandlers) getArtists(w http.ResponseWriter, r *http.Request) {
q := dbq.New(b.pool)
indexes, _, err := b.buildArtistIndexes(r.Context(), q)
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to load artists")
return
}
Write(w, r, ArtistsResponse{
Envelope: NewEnvelope("ok"),
Artists: ArtistsContainer{
IgnoredArticles: ignoredArticles,
Indexes: indexes,
},
})
}
// buildArtistIndexes fetches every artist, buckets them by first letter, and
// returns indexes + the unix-ms timestamp of the most recently updated artist.
func (b *browseHandlers) buildArtistIndexes(ctx context.Context, q *dbq.Queries) ([]Index, int64, error) {
artists, err := q.ListArtists(ctx)
if err != nil {
return nil, 0, err
}
buckets := make(map[string][]ArtistRef)
var order []string
var lastMod int64
for _, a := range artists {
if a.UpdatedAt.Valid {
ms := a.UpdatedAt.Time.UnixMilli()
if ms > lastMod {
lastMod = ms
}
}
albums, err := q.ListAlbumsByArtist(ctx, a.ID)
if err != nil {
return nil, 0, err
}
ref := artistRef(a, len(albums))
letter := indexLetter(a.SortName)
if _, seen := buckets[letter]; !seen {
order = append(order, letter)
}
buckets[letter] = append(buckets[letter], ref)
}
indexes := make([]Index, 0, len(order))
for _, letter := range order {
indexes = append(indexes, Index{Name: letter, Artists: buckets[letter]})
}
return indexes, lastMod, nil
}
// getArtist returns one artist with its nested albums. id is a UUID string.
func (b *browseHandlers) getArtist(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
if idStr == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
return
}
id, ok := parseUUID(idStr)
if !ok {
WriteFail(w, r, ErrDataNotFound, "Artist not found")
return
}
q := dbq.New(b.pool)
artist, err := q.GetArtistByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
WriteFail(w, r, ErrDataNotFound, "Artist not found")
return
}
WriteFail(w, r, ErrGeneric, "Failed to load artist")
return
}
albums, err := q.ListAlbumsByArtist(r.Context(), id)
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to load albums")
return
}
refs := make([]AlbumRef, 0, len(albums))
for _, a := range albums {
count, _ := q.CountTracksByAlbum(r.Context(), a.ID)
refs = append(refs, albumRef(a, artist.Name, int(count), 0))
}
Write(w, r, ArtistResponse{
Envelope: NewEnvelope("ok"),
Artist: ArtistDetail{
ID: uuidToID(artist.ID),
Name: artist.Name,
AlbumCount: len(albums),
Albums: refs,
},
})
}
// getAlbum returns one album with its full track list.
func (b *browseHandlers) getAlbum(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
if idStr == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
return
}
id, ok := parseUUID(idStr)
if !ok {
WriteFail(w, r, ErrDataNotFound, "Album not found")
return
}
q := dbq.New(b.pool)
album, err := q.GetAlbumByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
WriteFail(w, r, ErrDataNotFound, "Album not found")
return
}
WriteFail(w, r, ErrGeneric, "Failed to load album")
return
}
artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to load album artist")
return
}
tracks, err := q.ListTracksByAlbum(r.Context(), dbq.ListTracksByAlbumParams{AlbumID: id})
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to load tracks")
return
}
songs := make([]SongRef, 0, len(tracks))
for _, t := range tracks {
songs = append(songs, songRef(t, album.Title, artist.Name))
}
Write(w, r, AlbumResponse{
Envelope: NewEnvelope("ok"),
Album: albumDetail(album, artist.Name, songs),
})
}
// getSong returns a single track by id. Parent metadata (album/artist name)
// is included so clients can render the song outside an album context.
func (b *browseHandlers) getSong(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
if idStr == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
return
}
id, ok := parseUUID(idStr)
if !ok {
WriteFail(w, r, ErrDataNotFound, "Song not found")
return
}
q := dbq.New(b.pool)
track, err := q.GetTrackByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
WriteFail(w, r, ErrDataNotFound, "Song not found")
return
}
WriteFail(w, r, ErrGeneric, "Failed to load song")
return
}
album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to load song album")
return
}
artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to load song artist")
return
}
Write(w, r, SongResponse{
Envelope: NewEnvelope("ok"),
Song: songRef(track, album.Title, artist.Name),
})
}
// getAlbumList2 returns a paged album list filtered by type. Supported types
// match the Subsonic spec § getAlbumList2. "recent"/"frequent" require play
// history (M2) and currently return an empty list rather than an error so
// clients that poll them don't break.
func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
listType := params.Get("type")
if listType == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: type")
return
}
size := clampInt(atoiDefault(params.Get("size"), 10), 1, 500)
offset := atoiDefault(params.Get("offset"), 0)
if offset < 0 {
offset = 0
}
q := dbq.New(b.pool)
var albums []dbq.Album
var err error
switch listType {
case "newest":
albums, err = q.ListAlbumsNewest(r.Context(), dbq.ListAlbumsNewestParams{
Limit: int32(size), Offset: int32(offset),
})
case "alphabeticalByName":
albums, err = q.ListAlbumsAlphaByName(r.Context(), dbq.ListAlbumsAlphaByNameParams{
Limit: int32(size), Offset: int32(offset),
})
case "alphabeticalByArtist":
rows, rerr := q.ListAlbumsAlphaByArtist(r.Context(), dbq.ListAlbumsAlphaByArtistParams{
Limit: int32(size), Offset: int32(offset),
})
if rerr != nil {
err = rerr
break
}
albums = make([]dbq.Album, len(rows))
for i, row := range rows {
albums[i] = row.Album
}
case "random":
albums, err = q.ListAlbumsRandom(r.Context(), int32(size))
case "byGenre":
genre := params.Get("genre")
if genre == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre")
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{
Genre: genre, Lim: int32(size), Off: int32(offset),
})
case "recent", "frequent":
// Play history lands in M2; return empty to keep clients happy.
albums = nil
default:
WriteFail(w, r, ErrGeneric, "Invalid list type: "+listType)
return
}
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to list albums")
return
}
refs, err := b.albumRefs(r.Context(), q, albums)
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to resolve albums")
return
}
Write(w, r, AlbumList2Response{
Envelope: NewEnvelope("ok"),
AlbumList2: AlbumList2Container{Albums: refs},
})
}
// search3 runs three independent searches — artist name, album title, song
// title — each bounded by its own count/offset. The Subsonic spec lets the
// caller page each facet separately, which is why the params triplicate.
func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
query := params.Get("query")
if query == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: query")
return
}
artistCount := clampInt(atoiDefault(params.Get("artistCount"), 20), 0, 500)
artistOffset := atoiDefault(params.Get("artistOffset"), 0)
albumCount := clampInt(atoiDefault(params.Get("albumCount"), 20), 0, 500)
albumOffset := atoiDefault(params.Get("albumOffset"), 0)
songCount := clampInt(atoiDefault(params.Get("songCount"), 20), 0, 500)
songOffset := atoiDefault(params.Get("songOffset"), 0)
q := dbq.New(b.pool)
// Subsonic clients send "" as a wildcard in some implementations; we pass
// the literal query through sqlc's ILIKE param so it matches substrings.
needle := &query
// JSON marshaling of nil slices produces `null`, which some clients reject.
// Initialize each facet to an empty slice so absent matches render as `[]`.
result := SearchResult3Container{
Artists: []ArtistRef{},
Albums: []AlbumRef{},
Songs: []SongRef{},
}
if artistCount > 0 {
artists, err := q.SearchArtists(r.Context(), dbq.SearchArtistsParams{
Column1: needle, Limit: int32(artistCount), Offset: int32(artistOffset),
})
if err != nil {
WriteFail(w, r, ErrGeneric, "Artist search failed")
return
}
for _, a := range artists {
albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
if aerr != nil {
WriteFail(w, r, ErrGeneric, "Artist search failed")
return
}
result.Artists = append(result.Artists, artistRef(a, len(albums)))
}
}
if albumCount > 0 {
albums, err := q.SearchAlbums(r.Context(), dbq.SearchAlbumsParams{
Column1: needle, Limit: int32(albumCount), Offset: int32(albumOffset),
})
if err != nil {
WriteFail(w, r, ErrGeneric, "Album search failed")
return
}
refs, rerr := b.albumRefs(r.Context(), q, albums)
if rerr != nil {
WriteFail(w, r, ErrGeneric, "Album search failed")
return
}
result.Albums = refs
}
if songCount > 0 {
tracks, err := q.SearchTracks(r.Context(), dbq.SearchTracksParams{
Column1: query, Limit: int32(songCount), Offset: int32(songOffset),
}) // UserID left zero (NULL) — Subsonic has no per-user quarantine context
if err != nil {
WriteFail(w, r, ErrGeneric, "Song search failed")
return
}
for _, t := range tracks {
album, aerr := q.GetAlbumByID(r.Context(), t.AlbumID)
if aerr != nil {
WriteFail(w, r, ErrGeneric, "Song search failed")
return
}
artist, aerr := q.GetArtistByID(r.Context(), t.ArtistID)
if aerr != nil {
WriteFail(w, r, ErrGeneric, "Song search failed")
return
}
result.Songs = append(result.Songs, songRef(t, album.Title, artist.Name))
}
}
Write(w, r, SearchResult3Response{
Envelope: NewEnvelope("ok"),
SearchResult3: result,
})
}
// albumRefs converts a list of dbq.Album rows to AlbumRef, resolving artist
// name and track count per album. Uses resolveArtistNames for the artist side
// (one query per distinct artist) and CountTracksByAlbum per album. For small
// page sizes (≤ 500) this is well within budget.
func (b *browseHandlers) albumRefs(ctx context.Context, q *dbq.Queries, albums []dbq.Album) ([]AlbumRef, error) {
// Always return a non-nil slice — JSON clients expect [] on empty results,
// not null. Same rationale applies to search3 facet slices.
if len(albums) == 0 {
return []AlbumRef{}, nil
}
ids := make([]pgtype.UUID, 0, len(albums))
for _, a := range albums {
ids = append(ids, a.ArtistID)
}
names, err := resolveArtistNames(ctx, q, ids)
if err != nil {
return nil, err
}
refs := make([]AlbumRef, 0, len(albums))
for _, a := range albums {
count, _ := q.CountTracksByAlbum(ctx, a.ID)
refs = append(refs, albumRef(a, names[uuidToID(a.ArtistID)], int(count), 0))
}
return refs, nil
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}