Merge pull request 'feat(subsonic): browse endpoints — getArtists/getAlbum/search3/… (#295)' (#8) from dev into main

This commit was merged in pull request #8.
This commit is contained in:
2026-04-19 19:13:12 +00:00
11 changed files with 1402 additions and 0 deletions
+252
View File
@@ -59,6 +59,96 @@ func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, erro
return i, err
}
const listAlbumsAlphaByArtist = `-- name: ListAlbumsAlphaByArtist :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, artists.sort_name AS artist_sort_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
ORDER BY artists.sort_name, albums.sort_title
LIMIT $1 OFFSET $2
`
type ListAlbumsAlphaByArtistParams struct {
Limit int32
Offset int32
}
type ListAlbumsAlphaByArtistRow struct {
Album Album
ArtistSortName string
}
// Sorted by the owning artist's sort_name. Needed by Subsonic's
// alphabeticalByArtist album list type.
func (q *Queries) ListAlbumsAlphaByArtist(ctx context.Context, arg ListAlbumsAlphaByArtistParams) ([]ListAlbumsAlphaByArtistRow, error) {
rows, err := q.db.Query(ctx, listAlbumsAlphaByArtist, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAlbumsAlphaByArtistRow
for rows.Next() {
var i ListAlbumsAlphaByArtistRow
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.ArtistSortName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsAlphaByName = `-- name: ListAlbumsAlphaByName :many
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums ORDER BY sort_title LIMIT $1 OFFSET $2
`
type ListAlbumsAlphaByNameParams struct {
Limit int32
Offset int32
}
func (q *Queries) ListAlbumsAlphaByName(ctx context.Context, arg ListAlbumsAlphaByNameParams) ([]Album, error) {
rows, err := q.db.Query(ctx, listAlbumsAlphaByName, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Album
for rows.Next() {
var i Album
if err := rows.Scan(
&i.ID,
&i.Title,
&i.SortTitle,
&i.ArtistID,
&i.ReleaseDate,
&i.Mbid,
&i.CoverArtPath,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsByArtist = `-- name: ListAlbumsByArtist :many
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title
`
@@ -93,6 +183,168 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID)
return items, nil
}
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
FROM albums
JOIN tracks ON tracks.album_id = albums.id
WHERE tracks.genre = $1
ORDER BY albums.id, albums.sort_title
LIMIT $2 OFFSET $3
`
type ListAlbumsByGenreParams struct {
Genre *string
Limit int32
Offset int32
}
// Album "belongs to" a genre if any of its tracks carry that genre.
func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) {
rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Album
for rows.Next() {
var i Album
if err := rows.Scan(
&i.ID,
&i.Title,
&i.SortTitle,
&i.ArtistID,
&i.ReleaseDate,
&i.Mbid,
&i.CoverArtPath,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsNewest = `-- name: ListAlbumsNewest :many
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums ORDER BY created_at DESC LIMIT $1 OFFSET $2
`
type ListAlbumsNewestParams struct {
Limit int32
Offset int32
}
func (q *Queries) ListAlbumsNewest(ctx context.Context, arg ListAlbumsNewestParams) ([]Album, error) {
rows, err := q.db.Query(ctx, listAlbumsNewest, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Album
for rows.Next() {
var i Album
if err := rows.Scan(
&i.ID,
&i.Title,
&i.SortTitle,
&i.ArtistID,
&i.ReleaseDate,
&i.Mbid,
&i.CoverArtPath,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsRandom = `-- name: ListAlbumsRandom :many
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums ORDER BY random() LIMIT $1
`
func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, error) {
rows, err := q.db.Query(ctx, listAlbumsRandom, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Album
for rows.Next() {
var i Album
if err := rows.Scan(
&i.ID,
&i.Title,
&i.SortTitle,
&i.ArtistID,
&i.ReleaseDate,
&i.Mbid,
&i.CoverArtPath,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const searchAlbums = `-- name: SearchAlbums :many
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums
WHERE title ILIKE '%' || $1 || '%'
ORDER BY sort_title
LIMIT $2 OFFSET $3
`
type SearchAlbumsParams struct {
Column1 *string
Limit int32
Offset int32
}
func (q *Queries) SearchAlbums(ctx context.Context, arg SearchAlbumsParams) ([]Album, error) {
rows, err := q.db.Query(ctx, searchAlbums, arg.Column1, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Album
for rows.Next() {
var i Album
if err := rows.Scan(
&i.ID,
&i.Title,
&i.SortTitle,
&i.ArtistID,
&i.ReleaseDate,
&i.Mbid,
&i.CoverArtPath,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAlbum = `-- name: UpsertAlbum :one
INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path)
VALUES ($1, $2, $3, $4, $5, $6)
+40
View File
@@ -78,6 +78,46 @@ func (q *Queries) ListArtists(ctx context.Context) ([]Artist, error) {
return items, nil
}
const searchArtists = `-- name: SearchArtists :many
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists
WHERE name ILIKE '%' || $1 || '%'
ORDER BY sort_name
LIMIT $2 OFFSET $3
`
type SearchArtistsParams struct {
Column1 *string
Limit int32
Offset int32
}
func (q *Queries) SearchArtists(ctx context.Context, arg SearchArtistsParams) ([]Artist, error) {
rows, err := q.db.Query(ctx, searchArtists, arg.Column1, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Artist
for rows.Next() {
var i Artist
if err := rows.Scan(
&i.ID,
&i.Name,
&i.SortName,
&i.Mbid,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertArtist = `-- name: UpsertArtist :one
INSERT INTO artists (name, sort_name, mbid)
VALUES ($1, $2, $3)
+60
View File
@@ -11,6 +11,17 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
SELECT count(*) FROM tracks WHERE album_id = $1
`
func (q *Queries) CountTracksByAlbum(ctx context.Context, albumID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countTracksByAlbum, albumID)
var count int64
err := row.Scan(&count)
return count, err
}
const getTrackByID = `-- name: GetTrackByID :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1
`
@@ -105,6 +116,55 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, albumID pgtype.UUID) ([
return items, nil
}
const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
WHERE title ILIKE '%' || $1 || '%'
ORDER BY title
LIMIT $2 OFFSET $3
`
type SearchTracksParams struct {
Column1 *string
Limit int32
Offset int32
}
func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]Track, error) {
rows, err := q.db.Query(ctx, searchTracks, arg.Column1, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Track
for rows.Next() {
var i Track
if err := rows.Scan(
&i.ID,
&i.Title,
&i.AlbumID,
&i.ArtistID,
&i.TrackNumber,
&i.DiscNumber,
&i.DurationMs,
&i.FilePath,
&i.FileSize,
&i.FileFormat,
&i.Bitrate,
&i.Mbid,
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertTrack = `-- name: UpsertTrack :one
INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number,
+33
View File
@@ -20,3 +20,36 @@ SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;
-- name: ListAlbumsByArtist :many
SELECT * FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title;
-- name: ListAlbumsAlphaByName :many
SELECT * FROM albums ORDER BY sort_title LIMIT $1 OFFSET $2;
-- name: ListAlbumsAlphaByArtist :many
-- Sorted by the owning artist's sort_name. Needed by Subsonic's
-- alphabeticalByArtist album list type.
SELECT sqlc.embed(albums), artists.sort_name AS artist_sort_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
ORDER BY artists.sort_name, albums.sort_title
LIMIT $1 OFFSET $2;
-- name: ListAlbumsNewest :many
SELECT * FROM albums ORDER BY created_at DESC LIMIT $1 OFFSET $2;
-- name: ListAlbumsRandom :many
SELECT * FROM albums ORDER BY random() LIMIT $1;
-- name: ListAlbumsByGenre :many
-- Album "belongs to" a genre if any of its tracks carry that genre.
SELECT DISTINCT ON (albums.id) albums.*
FROM albums
JOIN tracks ON tracks.album_id = albums.id
WHERE tracks.genre = $1
ORDER BY albums.id, albums.sort_title
LIMIT $2 OFFSET $3;
-- name: SearchAlbums :many
SELECT * FROM albums
WHERE title ILIKE '%' || $1 || '%'
ORDER BY sort_title
LIMIT $2 OFFSET $3;
+6
View File
@@ -18,3 +18,9 @@ SELECT * FROM artists WHERE name = $1 LIMIT 1;
-- name: ListArtists :many
SELECT * FROM artists ORDER BY sort_name;
-- name: SearchArtists :many
SELECT * FROM artists
WHERE name ILIKE '%' || $1 || '%'
ORDER BY sort_name
LIMIT $2 OFFSET $3;
+9
View File
@@ -27,3 +27,12 @@ SELECT * FROM tracks WHERE file_path = $1;
-- name: ListTracksByAlbum :many
SELECT * FROM tracks WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST;
-- name: CountTracksByAlbum :one
SELECT count(*) FROM tracks WHERE album_id = $1;
-- name: SearchTracks :many
SELECT * FROM tracks
WHERE title ILIKE '%' || $1 || '%'
ORDER BY title
LIMIT $2 OFFSET $3;
+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
}
+41
View File
@@ -0,0 +1,41 @@
package subsonic
import (
"encoding/json"
"net/http/httptest"
"testing"
)
// TestGetMusicFoldersShape pins the wire shape — a single folder with id=1,
// name "Music". Clients cache this response; breaking the shape invalidates
// their library state.
func TestGetMusicFoldersShape(t *testing.T) {
b := &browseHandlers{}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rest/getMusicFolders.view", nil)
b.getMusicFolders(rec, req)
var payload map[string]map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal: %v — body=%s", err, rec.Body)
}
inner := payload["subsonic-response"]
if inner["status"] != "ok" {
t.Fatalf("status = %v", inner["status"])
}
folders, ok := inner["musicFolders"].(map[string]any)
if !ok {
t.Fatalf("musicFolders missing: %+v", inner)
}
list, ok := folders["musicFolder"].([]any)
if !ok || len(list) != 1 {
t.Fatalf("musicFolder list = %+v", folders["musicFolder"])
}
first := list[0].(map[string]any)
if first["id"].(float64) != 1 {
t.Errorf("folder id = %v, want 1", first["id"])
}
if first["name"] != "Music" {
t.Errorf("folder name = %v, want Music", first["name"])
}
}
+9
View File
@@ -18,10 +18,19 @@ import (
// GET and POST are accepted; Subsonic's auth params live in the query string
// either way.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) {
b := &browseHandlers{pool: pool}
r.Route("/rest", func(sub chi.Router) {
sub.Use(Middleware(pool, cfg))
register(sub, "/ping", handlePing)
register(sub, "/getLicense", handleGetLicense)
register(sub, "/getMusicFolders", b.getMusicFolders)
register(sub, "/getIndexes", b.getIndexes)
register(sub, "/getArtists", b.getArtists)
register(sub, "/getArtist", b.getArtist)
register(sub, "/getAlbum", b.getAlbum)
register(sub, "/getAlbumList2", b.getAlbumList2)
register(sub, "/getSong", b.getSong)
register(sub, "/search3", b.search3)
_ = logger
})
}
+372
View File
@@ -0,0 +1,372 @@
package subsonic
import (
"context"
"encoding/xml"
"path"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// IDs on the wire are the bare UUID string. Subsonic endpoints know from
// context (stream.view → track, getArtist → artist) which table to resolve
// against, so we don't need type-prefixed IDs.
// uuidToID renders a pgtype.UUID as the canonical 8-4-4-4-12 string used by
// clients. Returns "" for a zero/invalid UUID so callers can omit the attr.
func uuidToID(u pgtype.UUID) string {
if !u.Valid {
return ""
}
b := u.Bytes
const hex = "0123456789abcdef"
out := make([]byte, 36)
j := 0
for i, x := range b {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[j] = '-'
j++
}
out[j] = hex[x>>4]
out[j+1] = hex[x&0x0f]
j += 2
}
return string(out[:j])
}
// parseUUID is the inverse of uuidToID — tolerant of the canonical hyphenated
// form and also raw hex. Callers that fail to parse should emit ErrDataNotFound.
func parseUUID(s string) (pgtype.UUID, bool) {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
return pgtype.UUID{}, false
}
return u, u.Valid
}
// MusicFolder matches Subsonic's /rest/getMusicFolders. v1 always reports a
// single folder because Minstrel has no multi-library concept yet.
type MusicFolder struct {
XMLName xml.Name `json:"-" xml:"musicFolder"`
ID int `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name,attr"`
}
type MusicFoldersContainer struct {
XMLName xml.Name `json:"-" xml:"musicFolders"`
MusicFolders []MusicFolder `json:"musicFolder" xml:"musicFolder"`
}
type MusicFoldersResponse struct {
Envelope
MusicFolders MusicFoldersContainer `json:"musicFolders" xml:"musicFolders"`
}
// Index groups artists by the first letter of their sort name.
type Index struct {
XMLName xml.Name `json:"-" xml:"index"`
Name string `json:"name" xml:"name,attr"`
Artists []ArtistRef `json:"artist" xml:"artist"`
}
// ArtistsContainer is the body of getArtists (ID3 namespace).
type ArtistsContainer struct {
XMLName xml.Name `json:"-" xml:"artists"`
IgnoredArticles string `json:"ignoredArticles" xml:"ignoredArticles,attr"`
Indexes []Index `json:"index" xml:"index"`
}
type ArtistsResponse struct {
Envelope
Artists ArtistsContainer `json:"artists" xml:"artists"`
}
// IndexesContainer is the body of the legacy getIndexes. Shape mirrors
// ArtistsContainer; some clients poll getIndexes and others getArtists.
type IndexesContainer struct {
XMLName xml.Name `json:"-" xml:"indexes"`
LastModified int64 `json:"lastModified" xml:"lastModified,attr"`
IgnoredArticles string `json:"ignoredArticles" xml:"ignoredArticles,attr"`
Indexes []Index `json:"index" xml:"index"`
}
type IndexesResponse struct {
Envelope
Indexes IndexesContainer `json:"indexes" xml:"indexes"`
}
// ArtistRef is the artist projection used in listings and as an album/song
// child reference.
type ArtistRef struct {
XMLName xml.Name `json:"-" xml:"artist"`
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name,attr"`
AlbumCount int `json:"albumCount,omitempty" xml:"albumCount,attr,omitempty"`
}
// ArtistDetail is the body of getArtist — artist attrs plus nested albums.
type ArtistDetail struct {
XMLName xml.Name `json:"-" xml:"artist"`
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name,attr"`
AlbumCount int `json:"albumCount" xml:"albumCount,attr"`
Albums []AlbumRef `json:"album" xml:"album"`
}
type ArtistResponse struct {
Envelope
Artist ArtistDetail `json:"artist" xml:"artist"`
}
// AlbumRef projects an album for listings (album list, search results, artist
// detail). Count/duration are optional — not every call populates them.
type AlbumRef struct {
XMLName xml.Name `json:"-" xml:"album"`
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name,attr"`
Artist string `json:"artist" xml:"artist,attr"`
ArtistID string `json:"artistId" xml:"artistId,attr"`
CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"`
SongCount int `json:"songCount,omitempty" xml:"songCount,attr,omitempty"`
Duration int `json:"duration,omitempty" xml:"duration,attr,omitempty"`
Created string `json:"created,omitempty" xml:"created,attr,omitempty"`
Year int `json:"year,omitempty" xml:"year,attr,omitempty"`
Genre string `json:"genre,omitempty" xml:"genre,attr,omitempty"`
}
// AlbumDetail is getAlbum — album attrs plus nested song list.
type AlbumDetail struct {
XMLName xml.Name `json:"-" xml:"album"`
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"name,attr"`
Artist string `json:"artist" xml:"artist,attr"`
ArtistID string `json:"artistId" xml:"artistId,attr"`
CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"`
SongCount int `json:"songCount" xml:"songCount,attr"`
Duration int `json:"duration" xml:"duration,attr"`
Created string `json:"created,omitempty" xml:"created,attr,omitempty"`
Year int `json:"year,omitempty" xml:"year,attr,omitempty"`
Genre string `json:"genre,omitempty" xml:"genre,attr,omitempty"`
Songs []SongRef `json:"song" xml:"song"`
}
type AlbumResponse struct {
Envelope
Album AlbumDetail `json:"album" xml:"album"`
}
// SongRef is the track projection used in album detail, getSong, search3,
// and later streaming/now-playing responses.
type SongRef struct {
XMLName xml.Name `json:"-" xml:"song"`
ID string `json:"id" xml:"id,attr"`
Parent string `json:"parent,omitempty" xml:"parent,attr,omitempty"`
Title string `json:"title" xml:"title,attr"`
Album string `json:"album,omitempty" xml:"album,attr,omitempty"`
Artist string `json:"artist,omitempty" xml:"artist,attr,omitempty"`
ArtistID string `json:"artistId,omitempty" xml:"artistId,attr,omitempty"`
AlbumID string `json:"albumId,omitempty" xml:"albumId,attr,omitempty"`
Track int `json:"track,omitempty" xml:"track,attr,omitempty"`
DiscNumber int `json:"discNumber,omitempty" xml:"discNumber,attr,omitempty"`
Genre string `json:"genre,omitempty" xml:"genre,attr,omitempty"`
CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"`
Size int64 `json:"size,omitempty" xml:"size,attr,omitempty"`
ContentType string `json:"contentType,omitempty" xml:"contentType,attr,omitempty"`
Suffix string `json:"suffix,omitempty" xml:"suffix,attr,omitempty"`
Duration int `json:"duration,omitempty" xml:"duration,attr,omitempty"`
BitRate int `json:"bitRate,omitempty" xml:"bitRate,attr,omitempty"`
IsDir bool `json:"isDir" xml:"isDir,attr"`
Type string `json:"type" xml:"type,attr"`
}
type SongResponse struct {
Envelope
Song SongRef `json:"song" xml:"song"`
}
// AlbumList2Container is the body of getAlbumList2.
type AlbumList2Container struct {
XMLName xml.Name `json:"-" xml:"albumList2"`
Albums []AlbumRef `json:"album" xml:"album"`
}
type AlbumList2Response struct {
Envelope
AlbumList2 AlbumList2Container `json:"albumList2" xml:"albumList2"`
}
// SearchResult3 is the body of search3.
type SearchResult3Container struct {
XMLName xml.Name `json:"-" xml:"searchResult3"`
Artists []ArtistRef `json:"artist" xml:"artist"`
Albums []AlbumRef `json:"album" xml:"album"`
Songs []SongRef `json:"song" xml:"song"`
}
type SearchResult3Response struct {
Envelope
SearchResult3 SearchResult3Container `json:"searchResult3" xml:"searchResult3"`
}
// ---- conversion helpers ----
func artistRef(a dbq.Artist, albumCount int) ArtistRef {
return ArtistRef{
ID: uuidToID(a.ID),
Name: a.Name,
AlbumCount: albumCount,
}
}
// albumRef projects an album. artistName must be pre-resolved because Album
// only carries artist_id. songCount/duration are optional; pass 0 to omit.
func albumRef(a dbq.Album, artistName string, songCount, durationSec int) AlbumRef {
return AlbumRef{
ID: uuidToID(a.ID),
Name: a.Title,
Artist: artistName,
ArtistID: uuidToID(a.ArtistID),
CoverArt: coverArtID(a),
SongCount: songCount,
Duration: durationSec,
Created: timestamptzToRFC3339(a.CreatedAt),
Year: yearFromDate(a.ReleaseDate),
}
}
func albumDetail(a dbq.Album, artistName string, songs []SongRef) AlbumDetail {
dur := 0
for _, s := range songs {
dur += s.Duration
}
return AlbumDetail{
ID: uuidToID(a.ID),
Name: a.Title,
Artist: artistName,
ArtistID: uuidToID(a.ArtistID),
CoverArt: coverArtID(a),
SongCount: len(songs),
Duration: dur,
Created: timestamptzToRFC3339(a.CreatedAt),
Year: yearFromDate(a.ReleaseDate),
Songs: songs,
}
}
func songRef(t dbq.Track, albumTitle, artistName string) SongRef {
s := SongRef{
ID: uuidToID(t.ID),
Parent: uuidToID(t.AlbumID),
Title: t.Title,
Album: albumTitle,
Artist: artistName,
AlbumID: uuidToID(t.AlbumID),
ArtistID: uuidToID(t.ArtistID),
CoverArt: uuidToID(t.AlbumID),
Size: t.FileSize,
ContentType: contentTypeForFormat(t.FileFormat),
Suffix: strings.TrimPrefix(strings.ToLower(path.Ext(t.FilePath)), "."),
Duration: int((t.DurationMs + 500) / 1000),
IsDir: false,
Type: "music",
}
if t.TrackNumber != nil {
s.Track = int(*t.TrackNumber)
}
if t.DiscNumber != nil {
s.DiscNumber = int(*t.DiscNumber)
}
if t.Bitrate != nil {
s.BitRate = int(*t.Bitrate)
}
if t.Genre != nil {
s.Genre = *t.Genre
}
return s
}
// coverArtID uses the album ID as the cover-art key since Minstrel's getCoverArt
// will resolve from albums.cover_art_path. Returns "" when no cover art exists.
func coverArtID(a dbq.Album) string {
if a.CoverArtPath == nil || *a.CoverArtPath == "" {
return ""
}
return uuidToID(a.ID)
}
func yearFromDate(d pgtype.Date) int {
if !d.Valid {
return 0
}
return d.Time.Year()
}
func timestamptzToRFC3339(ts pgtype.Timestamptz) string {
if !ts.Valid {
return ""
}
return ts.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
}
// contentTypeForFormat maps the short file_format we record (mp3/flac/ogg/...)
// to a MIME type Subsonic clients recognize. Falls back to octet-stream.
func contentTypeForFormat(f string) string {
switch strings.ToLower(f) {
case "mp3":
return "audio/mpeg"
case "flac":
return "audio/flac"
case "ogg", "oga":
return "audio/ogg"
case "m4a", "aac":
return "audio/mp4"
case "opus":
return "audio/opus"
case "wav":
return "audio/wav"
}
return "application/octet-stream"
}
// indexLetter returns the bucket name used by getIndexes/getArtists. Treats
// a leading digit as "#" per Subsonic convention. Empty names bucket to "?".
func indexLetter(sortName string) string {
sortName = strings.TrimSpace(sortName)
if sortName == "" {
return "?"
}
r := []rune(sortName)[0]
switch {
case r >= '0' && r <= '9':
return "#"
case r >= 'a' && r <= 'z':
return strings.ToUpper(string(r))
case r >= 'A' && r <= 'Z':
return string(r)
}
return "?"
}
// resolveArtistNames fetches distinct artist rows for the given ids and
// returns a uuid-string → name map. Uses the bulk-per-call lookup pattern
// rather than N+1 per album to keep listing endpoints responsive.
func resolveArtistNames(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[string]string, error) {
seen := make(map[string]struct{}, len(ids))
out := make(map[string]string, len(ids))
for _, id := range ids {
key := uuidToID(id)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
a, err := q.GetArtistByID(ctx, id)
if err != nil {
return nil, err
}
out[key] = a.Name
}
return out, nil
}
+129
View File
@@ -0,0 +1,129 @@
package subsonic
import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
func TestUUIDRoundTrip(t *testing.T) {
cases := []string{
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"00000000-0000-0000-0000-000000000001",
}
for _, in := range cases {
u, ok := parseUUID(in)
if !ok {
t.Fatalf("parseUUID(%q) failed", in)
}
if got := uuidToID(u); got != in {
t.Errorf("uuidToID(parseUUID(%q)) = %q", in, got)
}
}
}
func TestUUIDToIDZero(t *testing.T) {
var u pgtype.UUID
if got := uuidToID(u); got != "" {
t.Errorf("zero uuid → %q, want empty", got)
}
}
func TestParseUUIDInvalid(t *testing.T) {
for _, in := range []string{"", "not-a-uuid", "xxx"} {
if _, ok := parseUUID(in); ok {
t.Errorf("parseUUID(%q) unexpectedly ok", in)
}
}
}
func TestIndexLetter(t *testing.T) {
cases := map[string]string{
"Radiohead": "R",
"the beatles": "T",
" massive": "M",
"4 Non Blondes": "#",
"": "?",
"*NSYNC": "?",
"Éowyn": "?",
}
for in, want := range cases {
if got := indexLetter(in); got != want {
t.Errorf("indexLetter(%q) = %q, want %q", in, got, want)
}
}
}
func TestContentTypeForFormat(t *testing.T) {
cases := map[string]string{
"mp3": "audio/mpeg",
"MP3": "audio/mpeg",
"flac": "audio/flac",
"ogg": "audio/ogg",
"oga": "audio/ogg",
"opus": "audio/opus",
"m4a": "audio/mp4",
"aac": "audio/mp4",
"wav": "audio/wav",
"unknown": "application/octet-stream",
}
for in, want := range cases {
if got := contentTypeForFormat(in); got != want {
t.Errorf("contentTypeForFormat(%q) = %q, want %q", in, got, want)
}
}
}
func TestYearFromDate(t *testing.T) {
d := pgtype.Date{Valid: true, Time: time.Date(1997, 6, 16, 0, 0, 0, 0, time.UTC)}
if got := yearFromDate(d); got != 1997 {
t.Errorf("yearFromDate = %d", got)
}
if got := yearFromDate(pgtype.Date{}); got != 0 {
t.Errorf("yearFromDate(zero) = %d, want 0", got)
}
}
func TestTimestamptzToRFC3339(t *testing.T) {
ts := pgtype.Timestamptz{
Valid: true,
Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
if got := timestamptzToRFC3339(ts); got != "2026-01-02T03:04:05Z" {
t.Errorf("rfc3339 = %q", got)
}
if got := timestamptzToRFC3339(pgtype.Timestamptz{}); got != "" {
t.Errorf("rfc3339(zero) = %q, want empty", got)
}
}
func TestAtoiDefault(t *testing.T) {
cases := []struct {
in string
def int
want int
}{
{"", 7, 7},
{"42", 0, 42},
{" 12 ", 0, 12},
{"junk", 99, 99},
}
for _, c := range cases {
if got := atoiDefault(c.in, c.def); got != c.want {
t.Errorf("atoiDefault(%q,%d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
func TestClampInt(t *testing.T) {
if got := clampInt(5, 1, 10); got != 5 {
t.Errorf("in-range = %d", got)
}
if got := clampInt(-3, 1, 10); got != 1 {
t.Errorf("below = %d", got)
}
if got := clampInt(99, 1, 10); got != 10 {
t.Errorf("above = %d", got)
}
}