navidrome/core/ffmpeg/flac_streaminfo_test.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

142 lines
3.9 KiB
Go

package ffmpeg
import (
"bytes"
"errors"
"io"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Decoded independently so the specs do not mirror the production bit-twiddling.
func readSampleRate(b []byte) int {
return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4
}
func readTotalSamples(b []byte) uint64 {
return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25])
}
var _ = Describe("patchFLACDuration", func() {
var fileFLAC []byte
// Zeroing total_samples reproduces what a piped transcode emits.
pipedFLAC := func() []byte {
b := bytes.Clone(fileFLAC)
b[21] &= 0xF0
clear(b[22:26])
return b
}
readAll := func(in []byte, duration float32) []byte {
out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
Expect(err).ToNot(HaveOccurred())
return out
}
BeforeEach(func() {
var err error
fileFLAC, err = os.ReadFile("tests/fixtures/test.flac")
Expect(err).ToNot(HaveOccurred())
Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate
})
It("fills in total_samples from the duration", func() {
out := readAll(pipedFLAC(), 1.0)
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
})
It("takes the sample rate from the header, not from the source file", func() {
in := pipedFLAC()
// Rewrite the header's rate to 48000, as -ar would.
in[18], in[19] = 0x0B, 0xB8
in[20] &= 0x0F
out := readAll(in, 2.0)
Expect(readSampleRate(out)).To(Equal(48000))
Expect(readTotalSamples(out)).To(Equal(uint64(96000)))
})
It("rounds to the nearest sample rather than truncating", func() {
// float32(0.7)*44100 is 30869.9995, so truncation would lose a sample.
out := readAll(pipedFLAC(), 0.7)
Expect(readTotalSamples(out)).To(Equal(uint64(30870)))
})
It("passes through when the duration overflows the 36-bit field", func() {
in := pipedFLAC()
Expect(readAll(in, 2e6)).To(Equal(in))
})
It("leaves everything after the header untouched", func() {
in := pipedFLAC()
out := readAll(in, 1.0)
Expect(out).To(HaveLen(len(in)))
Expect(out[26:]).To(Equal(in[26:]))
Expect(out[:18]).To(Equal(in[:18]))
})
It("leaves an already-populated total_samples alone", func() {
out := readAll(fileFLAC, 99.0)
Expect(out).To(Equal(fileFLAC))
})
It("passes through a stream that is not FLAC", func() {
in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes")
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through when the first metadata block is not STREAMINFO", func() {
in := pipedFLAC()
in[4] = 0x04 // VORBIS_COMMENT
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through a stream shorter than the STREAMINFO fields it patches", func() {
in := pipedFLAC()[:20]
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through an empty stream", func() {
Expect(readAll(nil, 1.0)).To(BeEmpty())
})
It("passes through when the duration is zero or negative", func() {
in := pipedFLAC()
Expect(readAll(in, 0)).To(Equal(in))
Expect(readAll(in, -5)).To(Equal(in))
})
It("passes through when the header declares no sample rate", func() {
in := pipedFLAC()
in[18], in[19] = 0, 0
in[20] &= 0x0F
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("propagates a read error from the underlying stream", func() {
_, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
Expect(err).To(MatchError("boom"))
})
It("closes the underlying stream", func() {
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
Expect(c.closed).To(BeTrue())
})
})
type errReader struct{}
func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }