c7f4adbcc3
Both return user's starred artists/albums/songs sorted liked_at DESC. Cap at 500 entries per category for v1 (Subsonic spec doesn't define pagination on these endpoints; M3+ can revisit if needed).
400 lines
13 KiB
Go
400 lines
13 KiB
Go
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"`
|
|
}
|
|
|
|
// StarredContainer is the body of getStarred / getStarred2.
|
|
type StarredContainer struct {
|
|
XMLName xml.Name `json:"-" xml:"starred2"`
|
|
Artists []ArtistRef `json:"artist" xml:"artist"`
|
|
Albums []AlbumRef `json:"album" xml:"album"`
|
|
Songs []SongRef `json:"song" xml:"song"`
|
|
}
|
|
|
|
type GetStarred2Response struct {
|
|
Envelope
|
|
Starred2 StarredContainer `json:"starred2" xml:"starred2"`
|
|
}
|
|
|
|
// GetStarredContainer mirrors getStarred (id3-less variant). Response uses
|
|
// the same shape; the only material difference from getStarred2 is the
|
|
// outer key name.
|
|
type GetStarredContainer struct {
|
|
XMLName xml.Name `json:"-" xml:"starred"`
|
|
Artists []ArtistRef `json:"artist" xml:"artist"`
|
|
Albums []AlbumRef `json:"album" xml:"album"`
|
|
Songs []SongRef `json:"song" xml:"song"`
|
|
}
|
|
|
|
type GetStarredResponse struct {
|
|
Envelope
|
|
Starred GetStarredContainer `json:"starred" xml:"starred"`
|
|
}
|
|
|
|
// ---- 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 returns the album UUID as the cover-art key. getCoverArt uses
|
|
// the album row to find art either in cover_art_path (when the scanner sets
|
|
// it) or via sidecar lookup in the album directory, so emitting the id
|
|
// unconditionally is safe — worst case getCoverArt returns a Subsonic 70.
|
|
func coverArtID(a dbq.Album) string {
|
|
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
|
|
}
|