mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
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.
This commit is contained in:
parent
404837799b
commit
89026012ab
5 changed files with 255 additions and 6 deletions
|
|
@ -27,11 +27,12 @@ type TranscodeOptions struct {
|
|||
Command string // DB command template (used to detect custom vs default)
|
||||
Format string // Target format (mp3, opus, aac, flac)
|
||||
FilePath string
|
||||
BitRate int // kbps, 0 = codec default
|
||||
SampleRate int // 0 = no constraint
|
||||
Channels int // 0 = no constraint
|
||||
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
|
||||
Offset int // seconds
|
||||
BitRate int // kbps, 0 = codec default
|
||||
SampleRate int // 0 = no constraint
|
||||
Channels int // 0 = no constraint
|
||||
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
|
||||
Offset int // seconds
|
||||
Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header.
|
||||
}
|
||||
|
||||
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
|
||||
|
|
@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
|
|||
} else {
|
||||
args = buildTemplateArgs(opts)
|
||||
}
|
||||
return e.start(ctx, args)
|
||||
out, err := e.start(ctx, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package ffmpeg
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() {
|
|||
})
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
|
||||
It("fills in total_samples on a piped FLAC transcode", func() {
|
||||
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
Format: "flac",
|
||||
FilePath: "tests/fixtures/test.flac",
|
||||
Duration: 1, // the fixture is exactly 1s at 44100Hz
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
out, err := io.ReadAll(stream)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(out[:4])).To(Equal("fLaC"))
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
|
||||
})
|
||||
|
||||
It("patches the duration net of the requested offset", func() {
|
||||
// The command has no %t, so ffmpeg still emits the whole fixture.
|
||||
// What is under test is the header arithmetic, not the audio.
|
||||
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
Format: "flac",
|
||||
FilePath: "tests/fixtures/test.flac",
|
||||
Duration: 3,
|
||||
Offset: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
out, err := io.ReadAll(stream)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("stderr capture", func() {
|
||||
|
|
|
|||
66
core/ffmpeg/flac_streaminfo.go
Normal file
66
core/ffmpeg/flac_streaminfo.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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))
|
||||
}
|
||||
142
core/ffmpeg/flac_streaminfo_test.go
Normal file
142
core/ffmpeg/flac_streaminfo_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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 }
|
||||
|
|
@ -268,6 +268,7 @@ func NewTranscodingCache() TranscodingCache {
|
|||
BitDepth: job.bitDepth,
|
||||
Channels: job.channels,
|
||||
Offset: job.offset,
|
||||
Duration: job.mf.Duration,
|
||||
})
|
||||
if err != nil {
|
||||
release()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue