From 71e8849dca4ce13a80cf20ea6b66aa6247838fe5 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 19:05:03 +0000 Subject: [PATCH] subsonic: browse response shapes and conversion helpers (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/subsonic/types.go | 372 +++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 internal/subsonic/types.go diff --git a/internal/subsonic/types.go b/internal/subsonic/types.go new file mode 100644 index 00000000..56dbf63c --- /dev/null +++ b/internal/subsonic/types.go @@ -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 +}