Last bullet of #367. From an album you like, one click to everything else from that year or in that genre. Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So both detail responses gained a derived `genres` array, computed from the entity's tracks rather than stored, since an album's tracks can legitimately disagree about genre. Split and trimmed identically to the browse index. That's the invariant this whole task turned on: if the chip's matching diverged from the index's splitting, a chip would lead to a page that doesn't contain the album you clicked from. No year link on artist detail. An artist spans many years, so a single one would be a lie about the discography — genres only there. Genre lookup failure is logged and degrades to no chips rather than failing the request; a navigation nicety must not 404 a detail page that otherwise loaded. `genres` is always an array at JSON, never null, matching how every other list field in this package is emitted. ## Type widening, and the TypeScript version of a lesson from earlier today Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture that constructs one. Six of them across three test files. That's the same shape as the Go signature changes that cost three CI rounds in #2453 — change a type, then go find everything that builds it — so I searched for the constructions before pushing instead of after. All six updated. Tests: the encoded href for a slash-bearing genre ("Rock/Pop" → ?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all. gofmt verified clean via docker rather than guessed.
196 lines
5.7 KiB
Go
196 lines
5.7 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
var errBadPaging = errors.New("invalid limit or offset")
|
|
|
|
// uuidToString renders a pgtype.UUID as a canonical hyphenated string.
|
|
// Returns "" when the UUID is invalid (unset). Duplicated from the subsonic
|
|
// package intentionally — the two packages must not depend on each other.
|
|
func uuidToString(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)
|
|
}
|
|
|
|
// parseUUID accepts the canonical hyphenated form produced by uuidToString.
|
|
// Returns ok=false on any malformed input so handlers can 400 cleanly.
|
|
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
|
|
}
|
|
|
|
// yearFromDate returns the 4-digit year from a pgtype.Date, or 0 when the
|
|
// date is unset. Album release years are optional at scan time.
|
|
func yearFromDate(d pgtype.Date) int {
|
|
if !d.Valid {
|
|
return 0
|
|
}
|
|
return d.Time.Year()
|
|
}
|
|
|
|
// durationMsToSec converts millisecond durations (as stored in tracks.duration_ms)
|
|
// to seconds, rounding to nearest. Callers display seconds; sub-second precision
|
|
// is not useful in UI.
|
|
func durationMsToSec(ms int32) int {
|
|
return int((ms + 500) / 1000)
|
|
}
|
|
|
|
// coverURL returns the /api relative URL for an album's cover art. The
|
|
// endpoint ships in Plan 3; until then it 404s, and the SPA does not wire
|
|
// <img src> to it.
|
|
func coverURL(albumID pgtype.UUID) string {
|
|
return "/api/albums/" + uuidToString(albumID) + "/cover"
|
|
}
|
|
|
|
// streamURL returns the /api relative URL for a track's audio stream. The
|
|
// endpoint ships in Plan 3.
|
|
func streamURL(trackID pgtype.UUID) string {
|
|
return "/api/tracks/" + uuidToString(trackID) + "/stream"
|
|
}
|
|
|
|
// streamURLWithExt returns the extension-bearing stream URL used by UPnP
|
|
// cast tokens. Sonos's URL probe gates duration detection on a recognizable
|
|
// audio file extension; the bare `/stream` shape reports TrackDuration=0
|
|
// and breaks seek/auto-advance. The bare /stream route stays mounted as an
|
|
// alias for legacy / web / Subsonic clients. See task #610.
|
|
func streamURLWithExt(trackID pgtype.UUID, ext string) string {
|
|
return streamURL(trackID) + "." + ext
|
|
}
|
|
|
|
// artistRefFrom projects a dbq.Artist into an ArtistRef without cover.
|
|
// albumCount must be pre-computed by the caller. Used by code paths that
|
|
// don't have a representative-album lookup at hand (artist detail, search,
|
|
// liked-artists list).
|
|
func artistRefFrom(a dbq.Artist, albumCount int) ArtistRef {
|
|
return ArtistRef{
|
|
ID: uuidToString(a.ID),
|
|
Name: a.Name,
|
|
SortName: a.SortName,
|
|
AlbumCount: albumCount,
|
|
}
|
|
}
|
|
|
|
// artistRefFromCovered projects a dbq.Artist into an ArtistRef with the
|
|
// derived CoverURL populated from a representative album id (nullable).
|
|
// Used by /api/home and /api/artists?sort=alpha which carry the lookup
|
|
// in the same query.
|
|
func artistRefFromCovered(a dbq.Artist, albumCount int, coverAlbumID pgtype.UUID) ArtistRef {
|
|
ref := artistRefFrom(a, albumCount)
|
|
if coverAlbumID.Valid {
|
|
ref.CoverURL = coverURL(coverAlbumID)
|
|
}
|
|
return ref
|
|
}
|
|
|
|
// albumRefFrom projects a dbq.Album into an AlbumRef. artistName must be
|
|
// pre-resolved because Album only carries artist_id. trackCount and
|
|
// durationSec may be 0 when the caller does not compute them.
|
|
func albumRefFrom(a dbq.Album, artistName string, trackCount, durationSec int) AlbumRef {
|
|
return AlbumRef{
|
|
ID: uuidToString(a.ID),
|
|
Title: a.Title,
|
|
SortTitle: a.SortTitle,
|
|
ArtistID: uuidToString(a.ArtistID),
|
|
ArtistName: artistName,
|
|
Year: yearFromDate(a.ReleaseDate),
|
|
TrackCount: trackCount,
|
|
DurationSec: durationSec,
|
|
CoverURL: coverURL(a.ID),
|
|
CoverArtSource: a.CoverArtSource,
|
|
}
|
|
}
|
|
|
|
// trackRefFrom projects a dbq.Track into a TrackRef. Parent names must be
|
|
// pre-resolved because Track only carries album_id and artist_id.
|
|
func trackRefFrom(t dbq.Track, albumTitle, artistName string) TrackRef {
|
|
ref := TrackRef{
|
|
ID: uuidToString(t.ID),
|
|
Title: t.Title,
|
|
AlbumID: uuidToString(t.AlbumID),
|
|
AlbumTitle: albumTitle,
|
|
ArtistID: uuidToString(t.ArtistID),
|
|
ArtistName: artistName,
|
|
DurationSec: durationMsToSec(t.DurationMs),
|
|
StreamURL: streamURL(t.ID),
|
|
}
|
|
if t.TrackNumber != nil {
|
|
ref.TrackNumber = int(*t.TrackNumber)
|
|
}
|
|
if t.DiscNumber != nil {
|
|
ref.DiscNumber = int(*t.DiscNumber)
|
|
}
|
|
return ref
|
|
}
|
|
|
|
// parsePaging reads limit/offset from the query string, applying defaults
|
|
// and clamping. Returns a 400-worthy error when the values are non-numeric;
|
|
// out-of-range values silently clamp (deliberate — UX).
|
|
func parsePaging(raw url.Values) (limit, offset int, err error) {
|
|
const (
|
|
defLimit = 50
|
|
maxLimit = 200
|
|
)
|
|
limit = defLimit
|
|
if s := strings.TrimSpace(raw.Get("limit")); s != "" {
|
|
n, perr := strconv.Atoi(s)
|
|
if perr != nil {
|
|
return 0, 0, errBadPaging
|
|
}
|
|
if n < 1 {
|
|
n = 1
|
|
}
|
|
if n > maxLimit {
|
|
n = maxLimit
|
|
}
|
|
limit = n
|
|
}
|
|
if s := strings.TrimSpace(raw.Get("offset")); s != "" {
|
|
n, perr := strconv.Atoi(s)
|
|
if perr != nil {
|
|
return 0, 0, errBadPaging
|
|
}
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
offset = n
|
|
}
|
|
return limit, offset, nil
|
|
}
|
|
|
|
// nonNilStrings guarantees a JSON array rather than null. The clients iterate
|
|
// these without a null check, matching how every other list field in this
|
|
// package is emitted.
|
|
func nonNilStrings(in []string) []string {
|
|
if in == nil {
|
|
return []string{}
|
|
}
|
|
return in
|
|
}
|