Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
5.7 KiB
Go
197 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 web and Subsonic clients.
|
|
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),
|
|
Unavailable: t.MissingSince.Valid,
|
|
}
|
|
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
|
|
}
|