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 (M464 #5001): 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 "|": 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 (task #610). 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) }