024493f2a7
Closes Scribe #614, #615, server half of #616 surfaced by the 2026-06-04 divergent-provider audit. - streamURL helper now used everywhere /api/tracks/{id}/stream is built (was inline concat in playlists.go and cast_token.go); add streamURLWithExt for the .ext cast variant. - audioContentType in media.go is the canonical file_format -> MIME lookup; mimeForFormat in cast_token.go is now a thin wrapper that overrides the unknown-format fallback to audio/mpeg (Sonos rejects octet-stream). Adds mpeg/vorbis/wave aliases. Subsonic's contentTypeForFormat stays frozen per docs. - coverart.ResolveAlbumPath extracted; api and subsonic both delegate to it.
186 lines
5.4 KiB
Go
186 lines
5.4 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
|
|
}
|