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 }