mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-20 22:47:22 -04:00
* refactor: rename core/transcode directory to core/stream * refactor: update all imports from core/transcode to core/stream * refactor: rename exported symbols to fit core/stream package name * refactor: simplify MediaStreamer interface to single NewStream method Remove the two-method interface (NewStream + DoStream) in favor of a single NewStream(ctx, mf, req) method. Callers are now responsible for fetching the MediaFile before calling NewStream. This removes the implicit DB lookup from the streamer, making it a pure streaming concern. * refactor: update all callers from DoStream to NewStream * chore: update wire_gen.go and stale comment for core/stream rename * refactor: update wire command to handle GO_BUILD_TAGS correctly Signed-off-by: Deluan <deluan@navidrome.org> * fix: distinguish not-found from internal errors in public stream handler * refactor: remove unused ID field from stream.Request * refactor: simplify ResolveRequestFromToken to receive *model.MediaFile Move MediaFile fetching responsibility to callers, making the method focused on token validation and request resolution. Remove ErrMediaNotFound (no longer produced). Update GetTranscodeStream handler to fetch the media file before calling ResolveRequestFromToken. * refactor: extend tokenTTL from 12 to 48 hours Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
149 lines
3.9 KiB
Go
149 lines
3.9 KiB
Go
package stream
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
)
|
|
|
|
const tokenTTL = 48 * time.Hour
|
|
|
|
// params contains the parameters extracted from a transcode token.
|
|
// TargetBitrate is in kilobits per second (kbps).
|
|
type params struct {
|
|
MediaID string
|
|
DirectPlay bool
|
|
TargetFormat string
|
|
TargetBitrate int
|
|
TargetChannels int
|
|
TargetSampleRate int
|
|
TargetBitDepth int
|
|
SourceUpdatedAt time.Time
|
|
}
|
|
|
|
// toClaimsMap converts a Decision into a JWT claims map for token encoding.
|
|
// Only non-zero transcode fields are included.
|
|
func (d *TranscodeDecision) toClaimsMap() map[string]any {
|
|
m := map[string]any{
|
|
"mid": d.MediaID,
|
|
"ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(),
|
|
jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(),
|
|
}
|
|
if d.CanDirectPlay {
|
|
m["dp"] = true
|
|
}
|
|
if d.CanTranscode && d.TargetFormat != "" {
|
|
m["f"] = d.TargetFormat
|
|
if d.TargetBitrate != 0 {
|
|
m["b"] = d.TargetBitrate
|
|
}
|
|
if d.TargetChannels != 0 {
|
|
m["ch"] = d.TargetChannels
|
|
}
|
|
if d.TargetSampleRate != 0 {
|
|
m["sr"] = d.TargetSampleRate
|
|
}
|
|
if d.TargetBitDepth != 0 {
|
|
m["bd"] = d.TargetBitDepth
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// paramsFromToken extracts and validates Params from a parsed JWT token.
|
|
// Returns an error if required claims (media ID, source timestamp) are missing.
|
|
func paramsFromToken(token jwt.Token) (*params, error) {
|
|
var p params
|
|
var mid string
|
|
if err := token.Get("mid", &mid); err == nil {
|
|
p.MediaID = mid
|
|
}
|
|
if p.MediaID == "" {
|
|
return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid)
|
|
}
|
|
|
|
var dp bool
|
|
if err := token.Get("dp", &dp); err == nil {
|
|
p.DirectPlay = dp
|
|
}
|
|
|
|
ua := getIntClaim(token, "ua")
|
|
if ua != 0 {
|
|
p.SourceUpdatedAt = time.Unix(int64(ua), 0)
|
|
}
|
|
if p.SourceUpdatedAt.IsZero() {
|
|
return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid)
|
|
}
|
|
|
|
var f string
|
|
if err := token.Get("f", &f); err == nil {
|
|
p.TargetFormat = f
|
|
}
|
|
p.TargetBitrate = getIntClaim(token, "b")
|
|
p.TargetChannels = getIntClaim(token, "ch")
|
|
p.TargetSampleRate = getIntClaim(token, "sr")
|
|
p.TargetBitDepth = getIntClaim(token, "bd")
|
|
return &p, nil
|
|
}
|
|
|
|
// getIntClaim extracts an int claim from a JWT token, handling the case where
|
|
// the value may be stored as int64 or float64 (common in JSON-based JWT libraries).
|
|
func getIntClaim(token jwt.Token, key string) int {
|
|
var v int
|
|
if err := token.Get(key, &v); err == nil {
|
|
return v
|
|
}
|
|
var v64 int64
|
|
if err := token.Get(key, &v64); err == nil {
|
|
return int(v64)
|
|
}
|
|
var f float64
|
|
if err := token.Get(key, &f); err == nil {
|
|
return int(f)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) {
|
|
return auth.EncodeToken(decision.toClaimsMap())
|
|
}
|
|
|
|
func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) {
|
|
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return paramsFromToken(token)
|
|
}
|
|
|
|
func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) {
|
|
p, err := s.parseTranscodeParams(token)
|
|
if err != nil {
|
|
return Request{}, errors.Join(ErrTokenInvalid, err)
|
|
}
|
|
if p.MediaID != mf.ID {
|
|
return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID)
|
|
}
|
|
if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) {
|
|
log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID,
|
|
"tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
|
|
return Request{}, ErrTokenStale
|
|
}
|
|
|
|
req := Request{Offset: offset}
|
|
if !p.DirectPlay && p.TargetFormat != "" {
|
|
req.Format = p.TargetFormat
|
|
req.BitRate = p.TargetBitrate
|
|
req.SampleRate = p.TargetSampleRate
|
|
req.BitDepth = p.TargetBitDepth
|
|
req.Channels = p.TargetChannels
|
|
}
|
|
return req, nil
|
|
}
|