452 lines
13 KiB
Go
452 lines
13 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
|
|
}
|
|
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: 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
|
|
}
|