// 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 }