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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user