mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
* refactor: rename core/transcode directory to core/stream * refactor: update all imports from core/transcode to core/stream * refactor: rename exported symbols to fit core/stream package name * refactor: simplify MediaStreamer interface to single NewStream method Remove the two-method interface (NewStream + DoStream) in favor of a single NewStream(ctx, mf, req) method. Callers are now responsible for fetching the MediaFile before calling NewStream. This removes the implicit DB lookup from the streamer, making it a pure streaming concern. * refactor: update all callers from DoStream to NewStream * chore: update wire_gen.go and stale comment for core/stream rename * refactor: update wire command to handle GO_BUILD_TAGS correctly Signed-off-by: Deluan <deluan@navidrome.org> * fix: distinguish not-found from internal errors in public stream handler * refactor: remove unused ID field from stream.Request * refactor: simplify ResolveRequestFromToken to receive *model.MediaFile Move MediaFile fetching responsibility to callers, making the method focused on token validation and request resolution. Remove ErrMediaNotFound (no longer produced). Update GetTranscodeStream handler to fetch the media file before calling ResolveRequestFromToken. * refactor: extend tokenTTL from 12 to 48 hours Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
488 lines
14 KiB
Go
488 lines
14 KiB
Go
package ffmpeg
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
)
|
|
|
|
// TranscodeOptions contains all parameters for a transcoding operation.
|
|
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
|
|
}
|
|
|
|
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
|
|
type AudioProbeResult struct {
|
|
Codec string `json:"codec"`
|
|
Profile string `json:"profile,omitempty"`
|
|
BitRate int `json:"bitRate"`
|
|
SampleRate int `json:"sampleRate"`
|
|
BitDepth int `json:"bitDepth"`
|
|
Channels int `json:"channels"`
|
|
}
|
|
|
|
type FFmpeg interface {
|
|
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
|
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
|
Probe(ctx context.Context, files []string) (string, error)
|
|
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
|
CmdPath() (string, error)
|
|
IsAvailable() bool
|
|
Version() string
|
|
}
|
|
|
|
func New() FFmpeg {
|
|
return &ffmpeg{}
|
|
}
|
|
|
|
const (
|
|
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
|
probeCmd = "ffmpeg %s -f ffmetadata"
|
|
probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
|
|
)
|
|
|
|
type ffmpeg struct{}
|
|
|
|
func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
|
|
if _, err := ffmpegCmd(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fileExists(opts.FilePath); err != nil {
|
|
return nil, err
|
|
}
|
|
var args []string
|
|
if isDefaultCommand(opts.Format, opts.Command) {
|
|
args = buildDynamicArgs(opts)
|
|
} else {
|
|
args = buildTemplateArgs(opts)
|
|
}
|
|
return e.start(ctx, args)
|
|
}
|
|
|
|
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
|
|
if _, err := ffmpegCmd(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fileExists(path); err != nil {
|
|
return nil, err
|
|
}
|
|
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
|
|
return e.start(ctx, args)
|
|
}
|
|
|
|
func fileExists(path string) error {
|
|
s, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.IsDir() {
|
|
return fmt.Errorf("'%s' is a directory", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
|
if _, err := ffmpegCmd(); err != nil {
|
|
return "", err
|
|
}
|
|
args := createProbeCommand(probeCmd, files)
|
|
log.Trace(ctx, "Executing ffmpeg command", "args", args)
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
|
output, _ := cmd.CombinedOutput()
|
|
return string(output), nil
|
|
}
|
|
|
|
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
|
|
if _, err := ffmpegCmd(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fileExists(filePath); err != nil {
|
|
return nil, err
|
|
}
|
|
args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
|
|
log.Trace(ctx, "Executing ffprobe command", "args", args)
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
|
|
}
|
|
return parseProbeOutput(output)
|
|
}
|
|
|
|
type probeOutput struct {
|
|
Streams []probeStream `json:"streams"`
|
|
Format probeFormat `json:"format"`
|
|
}
|
|
|
|
type probeFormat struct {
|
|
BitRate string `json:"bit_rate"`
|
|
}
|
|
|
|
type probeStream struct {
|
|
CodecName string `json:"codec_name"`
|
|
CodecType string `json:"codec_type"`
|
|
Profile string `json:"profile"`
|
|
SampleRate string `json:"sample_rate"`
|
|
BitRate string `json:"bit_rate"`
|
|
Channels int `json:"channels"`
|
|
BitsPerSample int `json:"bits_per_sample"`
|
|
BitsPerRawSample string `json:"bits_per_raw_sample"`
|
|
}
|
|
|
|
func parseProbeOutput(data []byte) (*AudioProbeResult, error) {
|
|
var output probeOutput
|
|
if err := json.Unmarshal(data, &output); err != nil {
|
|
return nil, fmt.Errorf("parsing ffprobe output: %w", err)
|
|
}
|
|
|
|
for _, s := range output.Streams {
|
|
if s.CodecType != "audio" {
|
|
continue
|
|
}
|
|
bitDepth := s.BitsPerSample
|
|
if bitDepth == 0 && s.BitsPerRawSample != "" {
|
|
bitDepth, _ = strconv.Atoi(s.BitsPerRawSample)
|
|
}
|
|
result := &AudioProbeResult{
|
|
Codec: s.CodecName,
|
|
Channels: s.Channels,
|
|
BitDepth: bitDepth,
|
|
}
|
|
|
|
// Profile: "unknown" → empty
|
|
if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") {
|
|
result.Profile = s.Profile
|
|
}
|
|
|
|
// Sample rate: string → int
|
|
if s.SampleRate != "" {
|
|
result.SampleRate, _ = strconv.Atoi(s.SampleRate)
|
|
}
|
|
|
|
// Bit rate: bps string → kbps int
|
|
if s.BitRate != "" {
|
|
bps, _ := strconv.Atoi(s.BitRate)
|
|
result.BitRate = bps / 1000
|
|
}
|
|
|
|
// Fallback to format-level bit_rate (needed for FLAC, Opus, etc.)
|
|
if result.BitRate == 0 && output.Format.BitRate != "" {
|
|
bps, _ := strconv.Atoi(output.Format.BitRate)
|
|
result.BitRate = bps / 1000
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("no audio stream found in ffprobe output")
|
|
}
|
|
|
|
func (e *ffmpeg) CmdPath() (string, error) {
|
|
return ffmpegCmd()
|
|
}
|
|
|
|
func (e *ffmpeg) IsAvailable() bool {
|
|
_, err := ffmpegCmd()
|
|
return err == nil
|
|
}
|
|
|
|
// Version executes ffmpeg -version and extracts the version from the output.
|
|
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
|
func (e *ffmpeg) Version() string {
|
|
cmd, err := ffmpegCmd()
|
|
if err != nil {
|
|
return "N/A"
|
|
}
|
|
out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec
|
|
if err != nil {
|
|
return "N/A"
|
|
}
|
|
parts := strings.Split(string(out), " ")
|
|
if len(parts) < 3 {
|
|
return "N/A"
|
|
}
|
|
return parts[2]
|
|
}
|
|
|
|
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
|
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
|
j := &ffCmd{args: args}
|
|
j.PipeReader, j.out = io.Pipe()
|
|
err := j.start(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
go j.wait()
|
|
return j, nil
|
|
}
|
|
|
|
type ffCmd struct {
|
|
*io.PipeReader
|
|
out *io.PipeWriter
|
|
args []string
|
|
cmd *exec.Cmd
|
|
}
|
|
|
|
func (j *ffCmd) start(ctx context.Context) error {
|
|
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
|
cmd.Stdout = j.out
|
|
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
|
cmd.Stderr = os.Stderr
|
|
} else {
|
|
cmd.Stderr = io.Discard
|
|
}
|
|
j.cmd = cmd
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("starting cmd: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (j *ffCmd) wait() {
|
|
if err := j.cmd.Wait(); err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
|
} else {
|
|
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
|
}
|
|
return
|
|
}
|
|
_ = j.out.Close()
|
|
}
|
|
|
|
// formatCodecMap maps target format to ffmpeg codec flag.
|
|
var formatCodecMap = map[string]string{
|
|
"mp3": "libmp3lame",
|
|
"opus": "libopus",
|
|
"aac": "aac",
|
|
"flac": "flac",
|
|
}
|
|
|
|
// formatOutputMap maps target format to ffmpeg output format flag (-f).
|
|
var formatOutputMap = map[string]string{
|
|
"mp3": "mp3",
|
|
"opus": "opus",
|
|
"aac": "ipod",
|
|
"flac": "flac",
|
|
}
|
|
|
|
// defaultCommands is used to detect whether a user has customized their transcoding command.
|
|
var defaultCommands = func() map[string]string {
|
|
m := make(map[string]string, len(consts.DefaultTranscodings))
|
|
for _, t := range consts.DefaultTranscodings {
|
|
m[t.TargetFormat] = t.Command
|
|
}
|
|
return m
|
|
}()
|
|
|
|
// isDefaultCommand returns true if the command matches the known default for this format.
|
|
func isDefaultCommand(format, command string) bool {
|
|
return defaultCommands[format] == command
|
|
}
|
|
|
|
// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
|
|
// including all transcoding parameters (bitrate, sample rate, channels).
|
|
func buildDynamicArgs(opts TranscodeOptions) []string {
|
|
cmdPath, _ := ffmpegCmd()
|
|
args := []string{cmdPath, "-i", opts.FilePath}
|
|
|
|
if opts.Offset > 0 {
|
|
args = append(args, "-ss", strconv.Itoa(opts.Offset))
|
|
}
|
|
|
|
args = append(args, "-map", "0:a:0")
|
|
|
|
if codec, ok := formatCodecMap[opts.Format]; ok {
|
|
args = append(args, "-c:a", codec)
|
|
}
|
|
|
|
if opts.BitRate > 0 {
|
|
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
|
|
}
|
|
if opts.SampleRate > 0 {
|
|
args = append(args, "-ar", strconv.Itoa(opts.SampleRate))
|
|
}
|
|
if opts.Channels > 0 {
|
|
args = append(args, "-ac", strconv.Itoa(opts.Channels))
|
|
}
|
|
// Only pass -sample_fmt for lossless output formats where bit depth matters.
|
|
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
|
|
// and passing interleaved formats like "s16" causes silent failures.
|
|
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
|
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
|
}
|
|
|
|
args = append(args, "-v", "0")
|
|
|
|
if outputFmt, ok := formatOutputMap[opts.Format]; ok {
|
|
args = append(args, "-f", outputFmt)
|
|
}
|
|
|
|
// For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming
|
|
if opts.Format == "aac" {
|
|
args = append(args, "-movflags", "frag_keyframe+empty_moov")
|
|
}
|
|
|
|
args = append(args, "-")
|
|
return args
|
|
}
|
|
|
|
// buildTemplateArgs handles user-customized command templates, with dynamic injection
|
|
// of sample rate, channels, and bit depth when requested by the transcode decision.
|
|
// Note: these flags are injected unconditionally when non-zero, even if the template
|
|
// already includes them. FFmpeg uses the last occurrence of duplicate flags.
|
|
func buildTemplateArgs(opts TranscodeOptions) []string {
|
|
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
|
|
|
|
// Dynamically inject -ar, -ac, and -sample_fmt before the output target
|
|
if opts.SampleRate > 0 {
|
|
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
|
}
|
|
if opts.Channels > 0 {
|
|
args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
|
|
}
|
|
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
|
args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
|
}
|
|
return args
|
|
}
|
|
|
|
// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
|
|
func injectBeforeOutput(args []string, flag, value string) []string {
|
|
if len(args) > 0 && args[len(args)-1] == "-" {
|
|
result := make([]string, 0, len(args)+2)
|
|
result = append(result, args[:len(args)-1]...)
|
|
result = append(result, flag, value, "-")
|
|
return result
|
|
}
|
|
return append(args, flag, value)
|
|
}
|
|
|
|
// isLosslessOutputFormat returns true if the format is a lossless audio format
|
|
// where preserving bit depth via -sample_fmt is meaningful.
|
|
// Note: this covers only formats ffmpeg can produce as output. For the full set of
|
|
// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat.
|
|
func isLosslessOutputFormat(format string) bool {
|
|
switch strings.ToLower(format) {
|
|
case "flac", "alac", "wav", "aiff":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
|
|
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
|
|
// (ffmpeg packs 24-bit samples into 32-bit containers).
|
|
func bitDepthToSampleFmt(bitDepth int) string {
|
|
switch bitDepth {
|
|
case 16:
|
|
return "s16"
|
|
case 32:
|
|
return "s32"
|
|
default:
|
|
// 24-bit and other depths: use s32 (the next valid container size)
|
|
return "s32"
|
|
}
|
|
}
|
|
|
|
// Path will always be an absolute path
|
|
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
|
var args []string
|
|
for _, s := range fixCmd(cmd) {
|
|
if strings.Contains(s, "%s") {
|
|
s = strings.ReplaceAll(s, "%s", path)
|
|
args = append(args, s)
|
|
if offset > 0 && !strings.Contains(cmd, "%t") {
|
|
args = append(args, "-ss", strconv.Itoa(offset))
|
|
}
|
|
} else {
|
|
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
|
|
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
|
args = append(args, s)
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
func createProbeCommand(cmd string, inputs []string) []string {
|
|
var args []string
|
|
for _, s := range fixCmd(cmd) {
|
|
if s == "%s" {
|
|
for _, inp := range inputs {
|
|
args = append(args, "-i", inp)
|
|
}
|
|
} else {
|
|
args = append(args, s)
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
func fixCmd(cmd string) []string {
|
|
split := strings.Fields(cmd)
|
|
cmdPath, _ := ffmpegCmd()
|
|
for i, s := range split {
|
|
if s == "ffmpeg" || s == "ffmpeg.exe" {
|
|
split[i] = cmdPath
|
|
}
|
|
if s == "ffprobe" || s == "ffprobe.exe" {
|
|
split[i] = ffprobePath(cmdPath)
|
|
}
|
|
}
|
|
return split
|
|
}
|
|
|
|
// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path.
|
|
func ffprobePath(ffmpegCmd string) string {
|
|
dir := filepath.Dir(ffmpegCmd)
|
|
base := filepath.Base(ffmpegCmd)
|
|
return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1))
|
|
}
|
|
|
|
func ffmpegCmd() (string, error) {
|
|
ffOnce.Do(func() {
|
|
if conf.Server.FFmpegPath != "" {
|
|
ffmpegPath = conf.Server.FFmpegPath
|
|
ffmpegPath, ffmpegErr = exec.LookPath(ffmpegPath)
|
|
} else {
|
|
ffmpegPath, ffmpegErr = exec.LookPath("ffmpeg")
|
|
if errors.Is(ffmpegErr, exec.ErrDot) {
|
|
log.Trace("ffmpeg found in current folder '.'")
|
|
ffmpegPath, ffmpegErr = exec.LookPath("./ffmpeg")
|
|
}
|
|
}
|
|
if ffmpegErr == nil {
|
|
log.Info("Found ffmpeg", "path", ffmpegPath)
|
|
return
|
|
}
|
|
})
|
|
return ffmpegPath, ffmpegErr
|
|
}
|
|
|
|
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
|
|
var (
|
|
ffOnce sync.Once
|
|
ffmpegPath string
|
|
ffmpegErr error
|
|
)
|