Files
vibesonic/internal/transcoding/transcoding.go
T

117 lines
3.2 KiB
Go

// Package transcoding runs ffmpeg to convert media on the fly for
// clients that can't play a file's native format or want a lower
// bitrate. It never buffers a whole output file in memory or on disk
// before sending it: ffmpeg's stdout is piped straight to the response,
// and the process is bounded (a small worker semaphore) and killed the
// moment the request context is cancelled (client disconnect).
package transcoding
import (
"context"
"fmt"
"io"
"os/exec"
"strconv"
)
// Format is a supported transcode target.
type Format string
const (
FormatMP3 Format = "mp3"
FormatOpus Format = "opus"
FormatAAC Format = "aac"
)
func (f Format) MimeType() string {
switch f {
case FormatOpus:
return "audio/ogg"
case FormatAAC:
return "audio/aac"
default:
return "audio/mpeg"
}
}
// ParseFormat maps a client-requested format string (as sent in the
// Subsonic "format" parameter) to a supported Format, defaulting to MP3
// for anything unrecognized — the same behavior real Subsonic servers
// use since mp3 is the universally-supported fallback.
func ParseFormat(s string) Format {
switch s {
case "opus":
return FormatOpus
case "aac", "m4a":
return FormatAAC
default:
return FormatMP3
}
}
// Options configures one transcode.
type Options struct {
Format Format
MaxBitRateKbps int // 0 means "no limit / use a sensible default"
TimeOffset float64 // seconds to seek into the source before transcoding
}
const defaultBitRateKbps = 192
const maxConcurrentJobs = 4
// Manager bounds how many ffmpeg processes can run at once, so a burst
// of client requests can't fork-bomb the host.
type Manager struct {
FFmpegPath string
sem chan struct{}
}
func NewManager(ffmpegPath string) *Manager {
if ffmpegPath == "" {
ffmpegPath = "ffmpeg"
}
return &Manager{FFmpegPath: ffmpegPath, sem: make(chan struct{}, maxConcurrentJobs)}
}
// Stream transcodes sourcePath per opts and copies the result to w as it
// is produced, never writing a complete temporary file to disk first.
// It blocks until the transcode finishes, the client disconnects (ctx
// cancelled, in which case ffmpeg is killed), or an error occurs.
func (m *Manager) Stream(ctx context.Context, w io.Writer, sourcePath string, opts Options) error {
select {
case m.sem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
defer func() { <-m.sem }()
args := m.buildArgs(sourcePath, opts, "-")
cmd := exec.CommandContext(ctx, m.FFmpegPath, args...)
cmd.Stdout = w
return cmd.Run()
}
func (m *Manager) buildArgs(sourcePath string, opts Options, output string) []string {
bitrate := opts.MaxBitRateKbps
if bitrate <= 0 {
bitrate = defaultBitRateKbps
}
var args []string
if opts.TimeOffset > 0 {
args = append(args, "-ss", strconv.FormatFloat(opts.TimeOffset, 'f', 2, 64))
}
args = append(args, "-i", sourcePath, "-vn", "-map_metadata", "-1")
switch opts.Format {
case FormatOpus:
args = append(args, "-c:a", "libopus", "-b:a", fmt.Sprintf("%dk", bitrate), "-f", "opus")
case FormatAAC:
args = append(args, "-c:a", "aac", "-b:a", fmt.Sprintf("%dk", bitrate), "-f", "adts")
default:
args = append(args, "-c:a", "libmp3lame", "-b:a", fmt.Sprintf("%dk", bitrate), "-f", "mp3")
}
args = append(args, "--", output)
return args
}