diff --git a/go.mod b/go.mod index 7a93a5b..ae0594a 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/peterbourgon/ff v1.2.0 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be github.com/wader/gormstore v0.0.0-20190302154359-acb787ba3755 + golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 // indirect golang.org/x/exp v0.0.0-20190121172915-509febef88a4 // indirect google.golang.org/appengine v1.6.1 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect diff --git a/go.sum b/go.sum index 13aebca..19ec739 100644 --- a/go.sum +++ b/go.sum @@ -119,6 +119,8 @@ golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 h1:DOmugCavvUtnUD114C1Wh+UgTgQZ4pMLzXxi1pSt+/Y= +golang.org/x/crypto v0.0.0-20200406173513-056763e48d71/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20180710024300-14dda7b62fcd/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4 h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index fcb9893..6db62a3 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -1,9 +1,9 @@ package ctrlsubsonic import ( + "io" "log" "net/http" - "os" "path" "time" @@ -21,6 +21,38 @@ import ( // b) return a non-nil spec.Response // _but not both_ +func streamGetTransPref(dbc *db.DB, userID int, client string) db.TranscodePreference { + pref := db.TranscodePreference{} + dbc. + Where("user_id=?", userID). + Where("client COLLATE NOCASE IN (?)", []string{"*", client}). + Order("client DESC"). // ensure "*" is last if it's there + First(&pref) + return pref +} + +func streamGetTrack(dbc *db.DB, trackID int) (*db.Track, error) { + track := db.Track{} + err := dbc. + Preload("Album"). + First(&track, trackID). + Error + return &track, err +} + +func streamUpdateStats(dbc *db.DB, userID, albumID int) { + play := db.Play{ + AlbumID: albumID, + UserID: userID, + } + dbc. + Where(play). + First(&play) + play.Time = time.Now() // for getAlbumList?type=recent + play.Count++ // for getAlbumList?type=frequent + dbc.Save(&play) +} + func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) id, err := params.GetInt("id") @@ -48,96 +80,50 @@ func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *s return nil } -func fileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} - -type serveTrackOptions struct { - track *db.Track - pref *db.TranscodePreference - maxBitrate int - cachePath string - musicPath string -} - -func serveTrackRaw(w http.ResponseWriter, r *http.Request, opts serveTrackOptions) { - log.Printf("serving raw %q\n", opts.track.Filename) - w.Header().Set("Content-Type", opts.track.MIME()) - trackPath := path.Join(opts.musicPath, opts.track.RelPath()) - http.ServeFile(w, r, trackPath) -} - -func serveTrackEncode(w http.ResponseWriter, r *http.Request, opts serveTrackOptions) { - profile := encode.Profiles()[opts.pref.Profile] - bitrate := encode.GetBitrate(opts.maxBitrate, profile) - trackPath := path.Join(opts.musicPath, opts.track.RelPath()) - cacheKey := encode.CacheKey(trackPath, opts.pref.Profile, bitrate) - cacheFile := path.Join(opts.cachePath, cacheKey) - if fileExists(cacheFile) { - log.Printf("serving transcode `%s`: cache [%s/%s] hit!\n", opts.track.Filename, profile.Format, bitrate) - http.ServeFile(w, r, cacheFile) - return - } - log.Printf("serving transcode `%s`: cache [%s/%s] miss!\n", opts.track.Filename, profile.Format, bitrate) - if err := encode.Encode(w, trackPath, cacheFile, profile, bitrate); err != nil { - log.Printf("error encoding %q: %v\n", trackPath, err) - return - } - log.Printf("serving transcode `%s`: encoded to [%s/%s] successfully\n", - opts.track.Filename, profile.Format, bitrate) -} - func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) id, err := params.GetInt("id") if err != nil { return spec.NewError(10, "please provide an `id` parameter") } - track := &db.Track{} - err = c.DB. - Preload("Album"). - First(track, id). - Error - if gorm.IsRecordNotFoundError(err) { + track, err := streamGetTrack(c.DB, id) + if err != nil { return spec.NewError(70, "media with id `%d` was not found", id) } user := r.Context().Value(CtxUser).(*db.User) - defer func() { - play := db.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.Get("c") - servOpts := serveTrackOptions{ - track: track, - musicPath: c.MusicPath, - } - pref := &db.TranscodePreference{} - err = c.DB. - Where("user_id=?", user.ID). - Where("client COLLATE NOCASE IN (?)", []string{"*", client}). - Order("client DESC"). // ensure "*" is last if it's there - First(pref). - Error - if gorm.IsRecordNotFoundError(err) { - serveTrackRaw(w, r, servOpts) + defer streamUpdateStats(c.DB, user.ID, track.Album.ID) + pref := streamGetTransPref(c.DB, user.ID, params.Get("c")) + trackPath := path.Join(c.MusicPath, track.RelPath()) + // + onInvalidProfile := func() error { + log.Printf("serving raw %q\n", track.Filename) + w.Header().Set("Content-Type", track.MIME()) + http.ServeFile(w, r, trackPath) return nil } - servOpts.pref = pref - servOpts.maxBitrate = params.GetIntOr("maxBitRate", 0) - servOpts.cachePath = c.CachePath - serveTrackEncode(w, r, servOpts) + onCacheHit := func(profile encode.Profile, path string) error { + log.Printf("serving transcode `%s`: cache [%s/%dk] hit!\n", + track.Filename, profile.Format, profile.Bitrate) + http.ServeFile(w, r, path) + return nil + } + onCacheMiss := func(profile encode.Profile) (io.Writer, error) { + log.Printf("serving transcode `%s`: cache [%s/%dk] miss!\n", + track.Filename, profile.Format, profile.Bitrate) + return w, nil + } + encodeOptions := encode.Options{ + TrackPath: trackPath, + CachePath: c.CachePath, + ProfileName: pref.Profile, + PreferredBitrate: params.GetIntOr("maxBitRate", 0), + OnInvalidProfile: onInvalidProfile, + OnCacheHit: onCacheHit, + OnCacheMiss: onCacheMiss, + } + if err := encode.Encode(encodeOptions); err != nil { + log.Printf("serving transcode `%s`: error: %v\n", track.Filename, err) + } return nil } @@ -147,17 +133,13 @@ func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec if err != nil { return spec.NewError(10, "please provide an `id` parameter") } - track := &db.Track{} - err = c.DB. - Preload("Album"). - First(track, id). - Error - if gorm.IsRecordNotFoundError(err) { + track, err := streamGetTrack(c.DB, id) + if err != nil { return spec.NewError(70, "media with id `%d` was not found", id) } - serveTrackRaw(w, r, serveTrackOptions{ - track: track, - musicPath: c.MusicPath, - }) + log.Printf("serving raw %q\n", track.Filename) + w.Header().Set("Content-Type", track.MIME()) + trackPath := path.Join(c.MusicPath, track.RelPath()) + http.ServeFile(w, r, trackPath) return nil } diff --git a/server/encode/encode.go b/server/encode/encode.go index 8dbca82..ccb4d2c 100644 --- a/server/encode/encode.go +++ b/server/encode/encode.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/exec" + "path" "github.com/cespare/xxhash" ) @@ -25,6 +26,14 @@ type Profile struct { forceRG bool } +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + func Profiles() map[string]Profile { return map[string]Profile{ "mp3": {"mp3", 128, []string{"-c:a", "libmp3lame"}, false}, @@ -34,9 +43,10 @@ func Profiles() map[string]Profile { } } -// copy command output to http response body using io.copy (simpler, but may increase ttfb) +// copy command output to http response body using io.copy +// (it's simpler, but may increase ttfb) //nolint:deadcode,unused // function may be switched later -func copyCmdOutput(out, cache io.Writer, pipeReader io.Reader) { +func cmdOutputCopy(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(out, cache) @@ -48,7 +58,7 @@ func copyCmdOutput(out, cache io.Writer, pipeReader io.Reader) { // copy command output to http response manually with a buffer (should reduce ttfb) //nolint:deadcode,unused // function may be switched later -func writeCmdOutput(out, cache io.Writer, pipeReader io.ReadCloser) { +func cmdOutputWrite(out, cache io.Writer, pipeReader io.ReadCloser) { buffer := make([]byte, buffLen) for { n, err := pipeReader.Read(buffer) @@ -74,13 +84,13 @@ func writeCmdOutput(out, cache io.Writer, pipeReader io.ReadCloser) { } // pre-format the ffmpeg command with needed options -func ffmpegCommand(filePath string, profile Profile, bitrate string) *exec.Cmd { +func ffmpegCommand(filePath string, profile Profile) *exec.Cmd { args := []string{ "-v", "0", "-i", filePath, "-map", "0:0", "-vn", - "-b:a", bitrate, + "-b:a", fmt.Sprintf("%dk", profile.Bitrate), } args = append(args, profile.ffmpegOptions...) if profile.forceRG { @@ -99,9 +109,9 @@ func ffmpegCommand(filePath string, profile Profile, bitrate string) *exec.Cmd { // but please do let me know if you see otherwise } -func Encode(out io.Writer, trackPath, cachePath string, profile Profile, bitrate string) error { +func encode(out io.Writer, trackPath, cachePath string, profile Profile) error { // prepare the command and file descriptors - cmd := ffmpegCommand(trackPath, profile, bitrate) + cmd := ffmpegCommand(trackPath, profile) pipeReader, pipeWriter := io.Pipe() cmd.Stdout = pipeWriter cmd.Stderr = pipeWriter @@ -110,12 +120,12 @@ func Encode(out io.Writer, trackPath, cachePath string, profile Profile, bitrate if err != nil { return fmt.Errorf("writing to cache file %q: %v: %w", cachePath, err, err) } - // still unsure if buffer version (writeCmdOutput) is any better than io.Copy-based one (copyCmdOutput) + // still unsure if buffer version (cmdOutputWrite) is any better than io.Copy-based one (cmdOutputCopy) // 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 writeCmdOutput(out, cacheFile, pipeReader) + go cmdOutputWrite(out, cacheFile, pipeReader) // run ffmpeg if err := cmd.Run(); err != nil { return fmt.Errorf("running ffmpeg: %w", err) @@ -129,18 +139,54 @@ func Encode(out io.Writer, trackPath, cachePath string, profile Profile, bitrate return nil } -// CacheKey generates the filename for the new transcode save -func CacheKey(sourcePath string, profile, bitrate string) string { - format := Profiles()[profile].Format - hash := xxhash.Sum64String(sourcePath) - return fmt.Sprintf("%x-%s-%s.%s", hash, profile, bitrate, format) +// cacheKey generates the filename for the new transcode save +func cacheKey(sourcePath string, profileName string, profile Profile) string { + return fmt.Sprintf("%x-%s-%dk.%s", + xxhash.Sum64String(sourcePath), profileName, profile.Bitrate, profile.Format, + ) } -// GetBitrate checks if the client forces bitrate lower than set in profile -func GetBitrate(clientBitrate int, profile Profile) string { - bitrate := profile.Bitrate - if clientBitrate != 0 && clientBitrate < bitrate { - bitrate = clientBitrate +// getBitrate checks if the client forces bitrate lower than set in profile +func getBitrate(preferred, defined int) int { + if preferred != 0 && preferred < defined { + return preferred } - return fmt.Sprintf("%dk", bitrate) + return defined +} + +type ( + OnInvalidProfileFunc func() error + OnCacheHitFunc func(Profile, string) error + OnCacheMissFunc func(Profile) (io.Writer, error) +) + +type Options struct { + TrackPath string + CachePath string + ProfileName string + PreferredBitrate int + OnInvalidProfile OnInvalidProfileFunc + OnCacheHit OnCacheHitFunc + OnCacheMiss OnCacheMissFunc +} + +func Encode(opts Options) error { + profile, ok := Profiles()[opts.ProfileName] + if !ok { + return opts.OnInvalidProfile() + } + profile.Bitrate = getBitrate(opts.PreferredBitrate, profile.Bitrate) + cacheKey := cacheKey(opts.TrackPath, opts.ProfileName, profile) + cachePath := path.Join(opts.CachePath, cacheKey) + if fileExists(cachePath) { + return opts.OnCacheHit(profile, cachePath) + } + writer, err := opts.OnCacheMiss(profile) + if err != nil { + return fmt.Errorf("starting cache serve: %w", err) + } + if err := encode(writer, opts.TrackPath, cachePath, profile); err != nil { + return fmt.Errorf("starting transcode: %w", err) + } + return nil }