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>
169 lines
5.8 KiB
Go
169 lines
5.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
)
|
|
|
|
// The leveled stream: a track rendered with the user's loudness
|
|
// gain applied, for the Sonos and UPnP speakers that fetch their own audio.
|
|
// See internal/library/leveled.go for how it is rendered.
|
|
|
|
// SignLeveledStreamToken signs a leveled stream URL. The gain is part of what
|
|
// is signed, so a speaker's URL cannot be edited into a different render.
|
|
// The message cannot collide with SignStreamToken's "<id>|<exp>": a plain
|
|
// token does not open a leveled stream, nor the reverse.
|
|
func SignLeveledStreamToken(secret []byte, trackID string, exp int64, g library.LeveledGain) string {
|
|
mac := hmac.New(sha256.New, secret)
|
|
lim := 0
|
|
if g.Limiter {
|
|
lim = 1
|
|
}
|
|
_, _ = fmt.Fprintf(mac, "%s|%d|leveled|%d|%d", trackID, exp, g.CentiDB, lim)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// VerifyLeveledStreamToken checks a token from SignLeveledStreamToken, and
|
|
// that it has not expired.
|
|
func VerifyLeveledStreamToken(secret []byte, trackID string, exp int64, g library.LeveledGain, token string) bool {
|
|
if time.Now().Unix() > exp {
|
|
return false
|
|
}
|
|
return hmac.Equal([]byte(SignLeveledStreamToken(secret, trackID, exp, g)), []byte(token))
|
|
}
|
|
|
|
// leveledStreamPath is the leveled stream's path. It ends in .flac because
|
|
// Sonos reads the format from the URL's extension.
|
|
func leveledStreamPath(trackID pgtype.UUID) string {
|
|
return "/api/tracks/" + uuidToString(trackID) + "/leveled.flac"
|
|
}
|
|
|
|
// leveledQuery is the query string a leveled URL carries.
|
|
func leveledQuery(g library.LeveledGain, token string, exp int64) string {
|
|
lim := "0"
|
|
if g.Limiter {
|
|
lim = "1"
|
|
}
|
|
return "?g=" + strconv.Itoa(g.CentiDB) + "&lim=" + lim +
|
|
"&token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
|
}
|
|
|
|
// leveledGainFor is the render request for one track under the user's
|
|
// preference. Unity when leveling is off or the track is unmeasured, which
|
|
// the caller answers with the plain stream.
|
|
func (h *handlers) leveledGainFor(ctx context.Context, userID, trackID pgtype.UUID, asAlbum bool) (library.LeveledGain, error) {
|
|
q := dbq.New(h.pool)
|
|
prefs, err := library.LoadNormalizationPrefs(ctx, q, userID)
|
|
if err != nil {
|
|
return library.LeveledGain{}, err
|
|
}
|
|
gains, err := library.ReplayGainForTracks(ctx, q, []pgtype.UUID{trackID})
|
|
if err != nil {
|
|
return library.LeveledGain{}, err
|
|
}
|
|
return library.NewLeveledGain(library.LeveledGainDB(prefs, gains[trackID], asAlbum), prefs.Boost), nil
|
|
}
|
|
|
|
// parseLeveledGain reads ?g= and ?lim= from a leveled URL.
|
|
func parseLeveledGain(r *http.Request) (library.LeveledGain, bool) {
|
|
c, err := strconv.Atoi(r.URL.Query().Get("g"))
|
|
if err != nil {
|
|
return library.LeveledGain{}, false
|
|
}
|
|
lim := r.URL.Query().Get("lim")
|
|
if lim != "0" && lim != "1" {
|
|
return library.LeveledGain{}, false
|
|
}
|
|
g := library.LeveledGain{CentiDB: c, Limiter: lim == "1"}
|
|
return g, g.Valid()
|
|
}
|
|
|
|
// handleGetLeveledStream implements GET /api/tracks/{id}/leveled.flac. It
|
|
// accepts a session, like the plain stream, or a leveled token: the gain in
|
|
// the query must be the one the token was signed for.
|
|
func (h *handlers) handleGetLeveledStream(w http.ResponseWriter, r *http.Request) {
|
|
rawID := chi.URLParam(r, "id")
|
|
g, ok := parseLeveledGain(r)
|
|
if !h.leveledAuthOk(r, rawID, g, ok) {
|
|
writeErr(w, apierror.ErrUnauthorized)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeErr(w, apierror.BadRequest("invalid_gain", "g and lim must describe a valid gain"))
|
|
return
|
|
}
|
|
if h.leveled == nil {
|
|
writeErr(w, &apierror.Error{Status: http.StatusServiceUnavailable, Code: "leveling_unavailable",
|
|
Message: "leveled streams are not available on this server"})
|
|
return
|
|
}
|
|
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
|
|
if apiErr != nil {
|
|
writeErr(w, apiErr)
|
|
return
|
|
}
|
|
path, err := h.leveled.Path(r.Context(), library.LeveledSource{
|
|
TrackID: rawID, Path: track.FilePath, DurationMs: track.DurationMs,
|
|
}, g)
|
|
switch {
|
|
case errors.Is(err, library.ErrLeveledSourceMissing):
|
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track file not found"})
|
|
return
|
|
case r.Context().Err() != nil:
|
|
return // the speaker hung up; the render carries on for its retry
|
|
case err != nil:
|
|
writeErrWithLog(w, h.logger, "leveled stream: render failed", apierror.InternalMsg("render failed", err))
|
|
return
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
// Evicted between render and open: rare, and the speaker retries.
|
|
writeErrWithLog(w, h.logger, "leveled stream: open render", apierror.InternalMsg("server error", err))
|
|
return
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "leveled stream: stat render", apierror.InternalMsg("server error", err))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "audio/flac")
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
|
http.ServeContent(w, r, "leveled.flac", info.ModTime(), f)
|
|
}
|
|
|
|
// leveledAuthOk mirrors streamAuthOk: a session, or a token signed over this
|
|
// track and this gain. A malformed gain fails the token path, since there is
|
|
// nothing it could have been signed over.
|
|
func (h *handlers) leveledAuthOk(r *http.Request, trackID string, g library.LeveledGain, gainOK bool) bool {
|
|
if _, ok := auth.UserFromContext(r.Context()); ok {
|
|
return true
|
|
}
|
|
if !gainOK {
|
|
return false
|
|
}
|
|
tok := r.URL.Query().Get("token")
|
|
exp, err := strconv.ParseInt(r.URL.Query().Get("exp"), 10, 64)
|
|
if tok == "" || err != nil {
|
|
return false
|
|
}
|
|
return VerifyLeveledStreamToken(h.streamSecret, trackID, exp, g, tok)
|
|
}
|