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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user