move transcoding stuff to "encode" package
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
package ctrlsubsonic
|
||||
package encode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"io"
|
||||
"os"
|
||||
@@ -12,15 +11,15 @@ import (
|
||||
"github.com/cespare/xxhash"
|
||||
)
|
||||
|
||||
type encoderProfile struct {
|
||||
format string
|
||||
bitrate int
|
||||
type Profile struct {
|
||||
Format string
|
||||
Bitrate string
|
||||
ffmpegOptions []string
|
||||
forceRG bool
|
||||
}
|
||||
|
||||
var (
|
||||
encProfiles = map[string]*encoderProfile{
|
||||
Profiles = map[string]*Profile{
|
||||
"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},
|
||||
@@ -29,64 +28,12 @@ var (
|
||||
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) {
|
||||
//nolint:deadcode,unused
|
||||
func copyCmdOutput(out, cache io.Writer, pipeReader io.Reader) {
|
||||
// Set up a MultiWriter to feed the command output
|
||||
// to both cache file and HTTP response:
|
||||
w := io.MultiWriter(res, cache)
|
||||
w := io.MultiWriter(out, cache)
|
||||
|
||||
// Start copying!
|
||||
if _, err := io.Copy(w, pipeReader); err != nil {
|
||||
@@ -95,7 +42,8 @@ func copyCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeR
|
||||
}
|
||||
|
||||
// Copy command output to HTTP response manually with a buffer (should reduce TTFB)
|
||||
func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.PipeReader) {
|
||||
//nolint:deadcode,unused
|
||||
func writeCmdOutput(out, cache io.Writer, pipeReader io.ReadCloser) {
|
||||
buffer := make([]byte, bufLen)
|
||||
for {
|
||||
n, err := pipeReader.Read(buffer)
|
||||
@@ -105,7 +53,7 @@ func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.Pipe
|
||||
}
|
||||
|
||||
data := buffer[0:n]
|
||||
_, err = res.Write(data)
|
||||
_, err = out.Write(data)
|
||||
if err != nil {
|
||||
fmt.Printf("Error while writing HTTP response: %s\n", err)
|
||||
}
|
||||
@@ -115,7 +63,7 @@ func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.Pipe
|
||||
fmt.Printf("Error while writing cache file: %s\n", err)
|
||||
}
|
||||
|
||||
if f, ok := res.(http.Flusher); ok {
|
||||
if f, ok := out.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
// reset buffer
|
||||
@@ -125,16 +73,8 @@ func writeCmdOutput(res http.ResponseWriter, cache *os.File, pipeReader *io.Pipe
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
func ffmpegCommand(filePath string, profile *Profile, bitrate string) *exec.Cmd {
|
||||
ffmpegArgs := []string{
|
||||
"-v", "0", "-i", filePath, "-map", "0:0",
|
||||
"-vn", "-b:a", bitrate,
|
||||
@@ -151,32 +91,58 @@ func ffmpegCommand(filePath string, profile *encoderProfile, bitrate string) *ex
|
||||
"-metadata", "replaygain_track_peak=",
|
||||
)
|
||||
}
|
||||
ffmpegArgs = append(ffmpegArgs, "-f", profile.format, "-")
|
||||
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"
|
||||
func Encode(out io.Writer, trackPath, cachePath string, profile *Profile, bitrate string) error {
|
||||
// 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)
|
||||
}
|
||||
if client == "Jamstash" {
|
||||
return "opus_rg"
|
||||
|
||||
//// 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(out, cacheFile, pipeReader)
|
||||
|
||||
// Run FFmpeg:
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to encode `%s`: %s\n", trackPath, err)
|
||||
}
|
||||
return "opus"
|
||||
|
||||
// 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, profile.Bitrate)
|
||||
}
|
||||
|
||||
// Generate cache key (file name). For, you know, encoded tracks cache.
|
||||
func getCacheKey(sourcePath string, profile string, bitrate string) string {
|
||||
format := encProfiles[profile].format
|
||||
func CacheKey(sourcePath string, profile string, bitrate string) string {
|
||||
format := Profiles[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 {
|
||||
func GetBitrate(clientBitrate int, profile *Profile) string {
|
||||
bitrate := profile.Bitrate
|
||||
if clientBitrate != 0 && clientBitrate < profile.Bitrate {
|
||||
bitrate = clientBitrate
|
||||
}
|
||||
return fmt.Sprintf("%dk", bitrate)
|
||||
@@ -1,7 +1,9 @@
|
||||
package ctrlsubsonic
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
@@ -9,10 +11,31 @@ import (
|
||||
|
||||
"senan.xyz/g/gonic/db"
|
||||
"senan.xyz/g/gonic/mime"
|
||||
"senan.xyz/g/gonic/server/ctrlsubsonic/encode"
|
||||
"senan.xyz/g/gonic/server/ctrlsubsonic/params"
|
||||
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
|
||||
)
|
||||
|
||||
// Put special clients that can't handle Opus here:
|
||||
func encodeProfileFor(client string) string {
|
||||
switch client {
|
||||
case "Soundwaves":
|
||||
return "mp3_rg"
|
||||
case "Jamstash":
|
||||
return "opus_rg"
|
||||
default:
|
||||
return "opus"
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(filename string) bool {
|
||||
info, err := os.Stat(filename)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
// "raw" handlers are ones that don't always return a spec response.
|
||||
// it could be a file, stream, etc. so you must either
|
||||
// a) write to response writer
|
||||
@@ -60,25 +83,9 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
|
||||
if gorm.IsRecordNotFoundError(err) {
|
||||
return spec.NewError(70, "media with id `%d` was not found", id)
|
||||
}
|
||||
|
||||
client := params.GetOr("c", "generic")
|
||||
bitrate, err := params.GetInt("maxBitRate")
|
||||
if err != nil {
|
||||
bitrate = 0
|
||||
}
|
||||
|
||||
absPath := path.Join(
|
||||
c.MusicPath,
|
||||
track.Album.LeftPath,
|
||||
track.Album.RightPath,
|
||||
track.Filename,
|
||||
)
|
||||
streamTrack(w, r, absPath, client, bitrate, c.CachePath)
|
||||
|
||||
//
|
||||
// after we've served the file, mark the album as played
|
||||
user := r.Context().Value(CtxUser).(*db.User)
|
||||
play := db.Play{
|
||||
defer func() {
|
||||
user := r.Context().Value(CtxUser).(*model.User)
|
||||
play := model.Play{
|
||||
AlbumID: track.Album.ID,
|
||||
UserID: user.ID,
|
||||
}
|
||||
@@ -88,6 +95,32 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
|
||||
play.Time = time.Now() // for getAlbumList?type=recent
|
||||
play.Count++ // for getAlbumList?type=frequent
|
||||
c.DB.Save(&play)
|
||||
}()
|
||||
client := params.GetOr("c", "generic")
|
||||
maxBitrate, err := params.GetInt("maxBitRate")
|
||||
if err != nil {
|
||||
maxBitrate = 0
|
||||
}
|
||||
|
||||
absPath := path.Join(
|
||||
c.MusicPath,
|
||||
track.Album.LeftPath,
|
||||
track.Album.RightPath,
|
||||
track.Filename,
|
||||
)
|
||||
profileName := encodeProfileFor(client)
|
||||
profile := encode.Profiles[profileName]
|
||||
bitrate := encode.GetBitrate(maxBitrate, profile)
|
||||
cacheKey := encode.CacheKey(absPath, profileName, bitrate)
|
||||
cacheFile := path.Join(c.CachePath, cacheKey)
|
||||
if fileExists(cacheFile) {
|
||||
log.Printf("cache [%s/%s] hit!\n", profile.Format, bitrate)
|
||||
http.ServeFile(w, r, cacheFile)
|
||||
return
|
||||
}
|
||||
if err := encode.Encode(w, absPath, cacheFile, profile, bitrate); err != nil {
|
||||
log.Printf("cache [%s/%s] miss!\n", profile.Format, bitrate)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user