feat(subsonic): add browse handlers (#295)

Implement getMusicFolders, getIndexes, getArtists, getArtist, getAlbum,
getSong, getAlbumList2, and search3. Album list types supported: newest,
alphabeticalByName, alphabeticalByArtist, random, byGenre, recent, frequent
(recent/frequent stub to [] pending M2 play history).
This commit is contained in:
2026-04-19 19:07:50 +00:00
parent 71e8849dca
commit 252428a104
+451
View File
@@ -0,0 +1,451 @@
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(), 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
}
albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{
Genre: &genre, Limit: int32(size), Offset: 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: needle, Limit: int32(songCount), Offset: int32(songOffset),
})
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
}