mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* fix(stream): abort the response when a transcoded stream is truncated When a transcode failed after some audio had already been sent, Serve logged the error and returned nil, so Go finished the chunked body normally and the client received an apparently complete, silently short file. Symfonium users hit this on large offline syncs, and the worst path, ffmpeg dying mid-write behind the transcoding cache, produced no error and nothing in the log above Debug: the cache writer was closed plainly, so readers drained the truncated entry to a clean EOF. The root cause of that silence is an fscache limitation: Close is the only way to end a cache write, and Close always means "complete". This adopts the deluan/fscache fork, which adds CloseWithError: on failure copyAndClose now cancels the entry with the cause, so every attached reader fails mid-read with the real error instead of EOF, a late Get for the entry is refused, and the entry never reports a final size. The error travels inside the entry each reader holds, which makes per-generation delivery automatic and needs no bookkeeping on our side. With the failure arriving in-band, one change in Serve covers every mode: an io.Copy error after bytes are on the wire panics with http.ErrAbortHandler. Go aborts the response without the terminating chunk (RST_STREAM on HTTP/2), chi's Recoverer re-panics that value, and the deferred stream.Close() still runs, so the transcode limiter slot is released as before. Two behaviors improve as side effects. A transcoder that dies before its first byte now yields a Subsonic error response instead of a 200 with an empty body, since the failure reaches Serve as an error while the status is still unsent; genuinely empty output (clean EOF, exit 0) keeps the 200. And a failed entry's invalidation no longer defers its unlink past a replacement entry re-creating the same file, because canceling already closed its readers. * fix(cache): warn when the cache writer cannot report failures to readers The CloseWithError capability comes from the fscache fork via a go.mod replace directive, and a type assertion picks it up. If that directive is ever lost, the assertion fails silently, readers of a dead writer go back to draining a truncated entry to a clean EOF, and nothing says so. Two layers against that: a warning on the failure path when the writer lacks the capability, and a test that asserts the writer fscache returns carries it, so losing the fork fails CI instead of a listener's download. * build: point the fscache replace at the fork's master deluan/fscache#1 is merged; pin the merge commit instead of the review branch. Pinned by sha because the module proxy still resolves the fork's master ref to its pre-merge commit. * build: reference the upstream fscache PR in the replace comment The replace itself must keep pointing at the fork: the commit only exists in djherbis/fscache under refs/pull/22/head, which the Go module fetcher cannot resolve (verified: unknown revision for both short and full sha). The same commit is advertised on the fork's master, so that is the fetchable source.
305 lines
10 KiB
Go
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,
|
|
})
|
|
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 ""
|
|
}
|