mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 19:07:12 +02:00
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.
66 lines
1.8 KiB
Go
66 lines
1.8 KiB
Go
package ffmpeg
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
)
|
|
|
|
const (
|
|
flacPrefixLen = 26 // through the last total_samples byte
|
|
flacMaxTotalSamples = 1<<36 - 1
|
|
)
|
|
|
|
// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0
|
|
// when writing to a pipe, since a decoder cannot seek a cached FLAC without it.
|
|
func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser {
|
|
if duration <= 0 {
|
|
return r
|
|
}
|
|
return &flacPatcher{ReadCloser: r, duration: duration}
|
|
}
|
|
|
|
type flacPatcher struct {
|
|
io.ReadCloser
|
|
duration float32
|
|
// Peeking here rather than in the constructor keeps Transcode from blocking
|
|
// until ffmpeg has emitted its first bytes.
|
|
stream io.Reader
|
|
}
|
|
|
|
func (f *flacPatcher) Read(p []byte) (int, error) {
|
|
if f.stream == nil {
|
|
prefix := make([]byte, flacPrefixLen)
|
|
n, err := io.ReadFull(f.ReadCloser, prefix)
|
|
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
return 0, err
|
|
}
|
|
prefix = prefix[:n]
|
|
if err == nil {
|
|
setFLACTotalSamples(prefix, f.duration)
|
|
}
|
|
f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser)
|
|
}
|
|
return f.stream.Read(p)
|
|
}
|
|
|
|
// setFLACTotalSamples takes the rate from the header rather than the transcode
|
|
// options, so a resampled (-ar) output still gets the right count.
|
|
func setFLACTotalSamples(prefix []byte, duration float32) {
|
|
if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 {
|
|
return
|
|
}
|
|
// 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples
|
|
info := binary.BigEndian.Uint64(prefix[18:])
|
|
rate := info >> 44
|
|
if rate == 0 || info&flacMaxTotalSamples != 0 {
|
|
return
|
|
}
|
|
total := math.Round(float64(duration) * float64(rate))
|
|
if total > flacMaxTotalSamples {
|
|
return
|
|
}
|
|
binary.BigEndian.PutUint64(prefix[18:], info|uint64(total))
|
|
}
|