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 // 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 }