navidrome/core/stream/media_streamer.go
Deluan 89026012ab fix(transcoding): make piped FLAC transcodes seekable
The FLAC muxer writes STREAMINFO before it knows the stream length, then
rewinds at the end to fill total_samples in. Navidrome pipes ffmpeg's stdout
(-f flac -), which is not seekable, so ffmpeg logs "unable to rewrite FLAC
header" and the field stays 0. A decoder needs total_samples to turn a
timestamp into a byte offset, so it reports an unknown duration and refuses to
seek. Online playback hides this because the client re-requests with a new
offset each time, but an offline copy is permanently unseekable, the symptom
reported against Symfonium where seeking a downloaded track jumps back to the
start.

Transcode now wraps its own output and rewrites total_samples as the first
bytes flow past. This lives in core/ffmpeg because the unseekable pipe is that
package's doing: buildDynamicArgs is what appends the trailing '-'. core/stream
only learns a target format and hands back an io.ReadCloser, so compensating
there leaked a transcoder implementation detail one layer up. TranscodeOptions
grows a Duration field alongside the existing Offset, which also puts the
duration-minus-offset arithmetic in the same function that emits -ss.

The wrapper runs on every transcode rather than only FLAC targets: the format
on a transcoding row is a declared target that nothing validates against the
command's actual -f, so a custom command can emit FLAC under any target_format.
The magic-byte check inside the wrapper is the authoritative test and costs a
26-byte peek. The output sample rate is read back out of the header ffmpeg just
wrote rather than taken from the transcode options, so a resampled (-ar) output
still gets the right count. Anything that is not a FLAC stream with an unset
total_samples passes through byte for byte.

Measured on a 177s source: before, total_samples=0 and ffprobe reported
duration N/A; after, total_samples=7807023 and duration 177.03s, with the audio
payload byte-identical. This affects every piped FLAC regardless of the source
format; only FLAC stores an authoritative "unknown", which is why mp3, opus
and aac survive the same pipe.

No SEEKTABLE is synthesised and the MD5 is left zero: both are optional, and
decoders binary-search using total_samples alone.
2026-09-07 16:47:42 -04:00

305 lines
10 KiB
Go

package stream
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"strconv"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/req"
)
type MediaStreamer interface {
NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error)
}
type TranscodingCache cache.FileCache
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
return &mediaStreamer{
ds: ds,
transcoder: t,
cache: cache,
limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser),
}
}
type mediaStreamer struct {
ds model.DataStore
transcoder ffmpeg.FFmpeg
cache cache.FileCache
limiter TranscodeLimiter
}
type streamJob struct {
ms *mediaStreamer
mf *model.MediaFile
filePath string
format string
bitRate int
sampleRate int
bitDepth int
channels int
offset int
}
func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
}
// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding)
// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about
// the streaming request and whether the transcoding result was served from cache or not.
func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
var format string
var bitRate int
var cached bool
defer func() {
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
"bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
"user", userName(ctx), "transcoding", format != "raw",
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
}()
format = req.Format
bitRate = req.BitRate
if format == "" || format == "raw" {
format = "raw"
bitRate = 0
}
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
filePath := mf.AbsolutePath()
if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
s.ReadCloser = f
s.Seeker = f
s.format = mf.Suffix
return s, nil
}
job := &streamJob{
ms: ms,
mf: mf,
filePath: filePath,
format: format,
bitRate: bitRate,
sampleRate: req.SampleRate,
bitDepth: req.BitDepth,
channels: req.Channels,
offset: req.Offset,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
// Rate-limit rejections are already logged at warn level by the
// producer; treating them as cache failures here would both
// double-log and mask actual cache problems.
if !errors.Is(err, ErrTooManyTranscodes) {
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
}
return nil, err
}
cached = r.Cached
s.ReadCloser = r
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
return s, nil
}
type Stream struct {
ctx context.Context
mf *model.MediaFile
bitRate int
format string
io.ReadCloser
io.Seeker
}
func (s *Stream) Seekable() bool { return s.Seeker != nil }
func (s *Stream) Duration() float32 { return s.mf.Duration }
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
func (s *Stream) EstimatedContentLength() int {
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
}
// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent
// (supporting range requests). For non-seekable streams it writes directly and logs any errors.
// Returns the number of bytes written and an error only when it fails with 0 bytes written
// (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response).
// Once bytes are on the wire it panics with http.ErrAbortHandler instead, aborting the response.
// Empty output (0 bytes, no error) is logged but not treated as an error.
func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) {
if s.Seekable() {
http.ServeContent(w, r, s.Name(), s.ModTime(), s)
return -1, nil
}
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", s.ContentType())
if req.Params(r).BoolOr("estimateContentLength", false) {
length := strconv.Itoa(s.EstimatedContentLength())
log.Trace(ctx, "Estimated content-length", "contentLength", length)
w.Header().Set("Content-Length", length)
}
if r.Method == http.MethodHead {
go func() { _, _ = io.Copy(io.Discard, s) }()
return 0, nil
}
id := s.mf.ID
c, err := io.Copy(w, s)
if err != nil {
log.Error(ctx, "Error sending transcoded file", "id", id, err)
if c == 0 {
w.Header().Del("Content-Length")
return 0, fmt.Errorf("sending transcoded file: %w", err)
}
// The 200 is already sent, so dropping the connection is the only way to say "truncated".
panic(http.ErrAbortHandler)
}
if c == 0 {
log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+
"Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details",
"id", id, "format", s.ContentType())
} else {
log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c)
}
return c, nil
}
// NewStream creates a non-seekable Stream from the given components.
func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream {
return &Stream{
ctx: context.Background(),
mf: mf,
format: format,
bitRate: bitRate,
ReadCloser: r,
}
}
var (
onceTranscodingCache sync.Once
instanceTranscodingCache TranscodingCache
)
func GetTranscodingCache() TranscodingCache {
onceTranscodingCache.Do(func() {
instanceTranscodingCache = NewTranscodingCache()
})
return instanceTranscodingCache
}
func NewTranscodingCache() TranscodingCache {
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
job := arg.(*streamJob)
command := LookupTranscodeCommand(ctx, job.ms.ds, job.format)
if command == "" {
log.Error(ctx, "No transcoding command available", "format", job.format)
return nil, os.ErrInvalid
}
release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx))
if err != nil {
log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached",
"id", job.mf.ID, "user", userName(ctx),
"maxConcurrent", conf.Server.Transcoding.MaxConcurrent,
"maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser)
return nil, err
}
// Choose the context that drives the ffmpeg process.
//
// When the limiter is enabled, force the request context so a
// client disconnect cancels ffmpeg and frees the slot promptly.
// Otherwise a client could open many transcodes, disconnect
// immediately, and still leave the configured cap's worth of
// ffmpeg processes draining in the background — which is exactly
// the DoS the limiter is meant to prevent.
//
// When the limiter is disabled, preserve the legacy behavior
// governed by Transcoding.EnableCancellation so unchanged configs
// keep their previous observable behavior.
var transcodingCtx context.Context
if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation {
transcodingCtx = ctx
} else {
transcodingCtx = request.AddValues(context.Background(), ctx)
}
out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{
Command: command,
Format: job.format,
FilePath: job.filePath,
BitRate: job.bitRate,
SampleRate: job.sampleRate,
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
Duration: job.mf.Duration,
})
if err != nil {
release()
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
// Tie the slot to the ffmpeg process: copyAndClose calls Close
// on this reader after io.Copy returns, which is exactly when
// ffmpeg has exited (either EOF or context cancellation).
return &releasingReadCloser{ReadCloser: out, release: release}, nil
})
}
// userName extracts the username from the context for logging purposes.
func userName(ctx context.Context) string {
if user, ok := request.UserFrom(ctx); !ok {
return "UNKNOWN"
} else {
return user.UserName
}
}
// limiterKey returns the per-user bucket key used by the transcode limiter.
// For anonymous requests (e.g. public shares) it returns the empty string,
// which signals the limiter to skip the per-user cap entirely — otherwise
// every anonymous viewer of a public share would collide on the same key
// and starve each other within MaxConcurrentPerUser slots. The global cap
// still applies and remains the protection against runaway anonymous load.
func limiterKey(ctx context.Context) string {
if user, ok := request.UserFrom(ctx); ok {
return user.UserName
}
return ""
}