subsonic: browse response shapes and conversion helpers (#295)

Adds the Subsonic browse wire types (MusicFolder, Index, ArtistRef,
ArtistDetail, AlbumRef, AlbumDetail, SongRef, and their response
envelopes) with dual json+xml tags so a single struct serves both
formats. Includes UUID wire helpers (uuidToID/parseUUID), indexing
helpers (indexLetter), MIME mapping (contentTypeForFormat), and the
artist-name resolver used by album listings.

IDs are bare UUID strings on the wire — endpoint context disambiguates.
This commit is contained in:
2026-04-19 19:05:03 +00:00
parent f8249a12ac
commit 71e8849dca
+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
}