117 lines
3.2 KiB
Go
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
|
|
}
|