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