Files
gonic/server/ctrlsubsonic/handlers_cache.go

184 lines
5.3 KiB
Go

package ctrlsubsonic
import (
"fmt"
"net/http"
"path"
"io"
"os"
"os/exec"
"github.com/cespare/xxhash"
)
type encoderProfile struct {
format string
bitrate int
ffmpegOptions []string
forceRG bool
}
var (
encProfiles = map[string]*encoderProfile{
"mp3": {"mp3", 128, []string{"-c:a", "libmp3lame"}, false},
"mp3_rg": {"mp3", 128, []string{"-c:a", "libmp3lame"}, true},
"opus": {"opus", 96, []string{"-c:a", "libopus", "-vbr", "constrained"}, false},
"opus_rg": {"opus", 96, []string{"-c:a", "libopus", "-vbr", "constrained"}, true},
}
bufLen = 4096
)
func streamTrack(w http.ResponseWriter, r *http.Request, trackPath string, client string, clBitrate int, cachePath string) {
// Guess required format based on client:
profileName := detectFormat(client)
profile := encProfiles[profileName]
bitrate := getBitrate(clBitrate, profile)
cacheFile := path.Join(cachePath, getCacheKey(trackPath, profileName, bitrate))
if fileExists(cacheFile) {
fmt.Printf("`%s`: cache [%s/%s] hit!\n", trackPath, profile.format, bitrate)
http.ServeFile(w, r, cacheFile)
} else {
fmt.Printf("`%s`: cache [%s/%s] miss!\n", trackPath, profile.format, bitrate)
encodeTrack(w, trackPath, cacheFile, profile, bitrate)
}
}
func encodeTrack(w http.ResponseWriter, trackPath string, cachePath string, profile *encoderProfile, bitrate string) {
// Prepare the command and file descriptors:
cmd := ffmpegCommand(trackPath, profile, bitrate)
pipeReader, pipeWriter := io.Pipe()
cmd.Stdout = pipeWriter
cmd.Stderr = pipeWriter
// Create cache file:
cacheFile, err := os.Create(cachePath)
if err != nil {
fmt.Printf("Failed to write to cache file `%s`: %s\n", cachePath, err)
}
//// I'm still unsure if buffer version (writeCmdOutput) is any better than io.Copy-based one (copyCmdOutput).
//// My initial goal here is to start streaming response ASAP, with smallest TTFB. More testing needed. -- @spijet
// Start up writers for cache file and HTTP response:
// go copyCmdOutput(w, cacheFile, pipeReader)
go writeCmdOutput(w, cacheFile, pipeReader)
// Run FFmpeg:
err = cmd.Run()
if err != nil {
fmt.Printf("Failed to encode `%s`: %s\n", trackPath, err)
}
// Close all pipes and flush cache file:
pipeWriter.Close()
err = cacheFile.Sync()
if err != nil {
fmt.Printf("Failed to flush `%s`: %s\n", cachePath, err)
}
cacheFile.Close()
fmt.Printf("`%s`: Encoded track to [%s/%s] successfully\n", trackPath, profile.format, bitrate)
}
// Copy command output to HTTP response body using io.Copy (simpler, but may increase TTFB)
func copyCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeReader) {
// Set up a MultiWriter to feed the command output
// to both cache file and HTTP response:
w := io.MultiWriter(res, cache)
// Start copying!
if _, err := io.Copy(w, pipeReader); err != nil {
fmt.Printf("Error while writing encoded output: %s\n", err)
}
}
// Copy command output to HTTP response manually with a buffer (should reduce TTFB)
func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeReader) {
buffer := make([]byte, bufLen)
for {
n, err := pipeReader.Read(buffer)
if err != nil {
pipeReader.Close()
break
}
data := buffer[0:n]
_, err = res.Write(data)
if err != nil {
fmt.Printf("Error while writing HTTP response: %s\n", err)
}
_, err = cache.Write(data)
if err != nil {
fmt.Printf("Error while writing cache file: %s\n", err)
}
if f, ok := res.(http.Flusher); ok {
f.Flush()
}
//reset buffer
for i := 0; i < n; i++ {
buffer[i] = 0
}
}
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
// Pre-format the FFmpeg command with needed options:
func ffmpegCommand(filePath string, profile *encoderProfile, bitrate string) *exec.Cmd {
ffmpegArgs := []string{
"-v", "0", "-i", filePath, "-map", "0:0",
"-vn", "-b:a", bitrate,
}
ffmpegArgs = append(ffmpegArgs, profile.ffmpegOptions...)
if profile.forceRG {
ffmpegArgs = append(ffmpegArgs,
// Set up ReplayGain processing
"-af", "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled",
// Drop redundant ReplayGain tags
"-metadata", "replaygain_album_gain=",
"-metadata", "replaygain_album_peak=",
"-metadata", "replaygain_track_gain=",
"-metadata", "replaygain_track_peak=",
)
}
ffmpegArgs = append(ffmpegArgs, "-f", profile.format, "-")
return exec.Command("/usr/bin/ffmpeg", ffmpegArgs...)
}
// Put special clients that can't handle Opus here:
func detectFormat(client string) (profile string) {
if client == "Soundwaves" {
return "mp3_rg"
}
if client == "Jamstash" {
return "opus_rg"
}
return "opus"
}
// Generate cache key (file name). For, you know, encoded tracks cache.
func getCacheKey(sourcePath string, profile string, bitrate string) string {
format := encProfiles[profile].format
return fmt.Sprintf("%x-%s-%s.%s", xxhash.Sum64String(sourcePath), profile, bitrate, format)
}
// Check if client forces bitrate lower than set in profile:
func getBitrate(clientBitrate int, profile *encoderProfile) string {
bitrate := profile.bitrate
if clientBitrate != 0 && clientBitrate < profile.bitrate {
bitrate = clientBitrate
}
return fmt.Sprintf("%dk", bitrate)
}