From c79524e44eacbe541c775ff013a09dd6e121af2d Mon Sep 17 00:00:00 2001 From: sentriz Date: Thu, 20 Feb 2020 17:46:39 +0000 Subject: [PATCH] move transcoding stuff to "encode" package --- .../{handlers_cache.go => encode/encode.go} | 140 +++++++----------- server/ctrlsubsonic/handlers_raw.go | 67 ++++++--- 2 files changed, 103 insertions(+), 104 deletions(-) rename server/ctrlsubsonic/{handlers_cache.go => encode/encode.go} (60%) diff --git a/server/ctrlsubsonic/handlers_cache.go b/server/ctrlsubsonic/encode/encode.go similarity index 60% rename from server/ctrlsubsonic/handlers_cache.go rename to server/ctrlsubsonic/encode/encode.go index 2c02af8..44be907 100644 --- a/server/ctrlsubsonic/handlers_cache.go +++ b/server/ctrlsubsonic/encode/encode.go @@ -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,26 +63,18 @@ 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 + // 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 { +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) diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 92d56dc..2efef46 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -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,11 +83,23 @@ 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) } - + defer func() { + user := r.Context().Value(CtxUser).(*model.User) + play := model.Play{ + AlbumID: track.Album.ID, + UserID: user.ID, + } + c.DB. + Where(play). + First(&play) + play.Time = time.Now() // for getAlbumList?type=recent + play.Count++ // for getAlbumList?type=frequent + c.DB.Save(&play) + }() client := params.GetOr("c", "generic") - bitrate, err := params.GetInt("maxBitRate") + maxBitrate, err := params.GetInt("maxBitRate") if err != nil { - bitrate = 0 + maxBitrate = 0 } absPath := path.Join( @@ -73,21 +108,19 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R 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{ - AlbumID: track.Album.ID, - UserID: user.ID, + 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) } - c.DB. - Where(play). - First(&play) - play.Time = time.Now() // for getAlbumList?type=recent - play.Count++ // for getAlbumList?type=frequent - c.DB.Save(&play) return nil }