diff --git a/api/get_cover_art.go b/api/get_cover_art.go index 7daf9e558..22e19a331 100644 --- a/api/get_cover_art.go +++ b/api/get_cover_art.go @@ -31,7 +31,7 @@ func (c *GetCoverArtController) Get() { var img []byte - if mf.HasCoverArt { + if mf != nil && mf.HasCoverArt { img, err = readFromTag(mf.Path) beego.Debug("Serving cover art from", mf.Path) } else { diff --git a/api/stream.go b/api/stream.go index eea243f02..caf4c7ca5 100644 --- a/api/stream.go +++ b/api/stream.go @@ -1,44 +1,93 @@ package api import ( + "github.com/astaxie/beego" + "github.com/deluan/gosonic/api/responses" + "github.com/deluan/gosonic/domain" + "github.com/deluan/gosonic/stream" "github.com/deluan/gosonic/utils" "github.com/karlkfi/inject" - "github.com/deluan/gosonic/domain" - "github.com/deluan/gosonic/api/responses" - "github.com/astaxie/beego" "io" - "os" + "net/http" + "strconv" ) - type StreamController struct { BaseAPIController repo domain.MediaFileRepository + id string + mf *domain.MediaFile +} + +type flushWriter struct { + f http.Flusher + w io.Writer +} + +func (fw *flushWriter) Write(p []byte) (n int, err error) { + n, err = fw.w.Write(p) + if fw.f != nil { + fw.f.Flush() + } + return } func (c *StreamController) Prepare() { inject.ExtractAssignable(utils.Graph, &c.repo) -} -// For realtime transcoding, see : http://stackoverflow.com/questions/19292113/not-buffered-http-responsewritter-in-golang -func (c *StreamController) Get() { - id := c.GetParameter("id", "id parameter required") + c.id = c.GetParameter("id", "id parameter required") - mf, err := c.repo.Get(id) + mf, err := c.repo.Get(c.id) if err != nil { - beego.Error("Error reading mediafile", id, "from the database", ":", err) + beego.Error("Error reading mediafile", c.id, "from the database", ":", err) c.SendError(responses.ERROR_GENERIC, "Internal error") } - beego.Debug("Streaming file", mf.Path) - f, err := os.Open(mf.Path) - if err != nil { - beego.Warn("Error opening file", mf.Path, "-", err) - c.SendError(responses.ERROR_DATA_NOT_FOUND, "cover art not available") + if mf == nil { + beego.Error("MediaFile", c.id, "not found!") + c.SendError(responses.ERROR_DATA_NOT_FOUND) } - c.Ctx.Output.ContentType(mf.ContentType()) - io.Copy(c.Ctx.ResponseWriter, f) - - beego.Debug("Finished streaming of", mf.Path) + c.mf = mf +} + +func createFlusher(w http.ResponseWriter) io.Writer { + fw := flushWriter{w: w} + if f, ok := w.(http.Flusher); ok { + fw.f = f + } + return &fw +} + +// TODO Investigate why it is not flushing before closing the connection +func (c *StreamController) Stream() { + var maxBitRate int + c.Ctx.Input.Bind(&maxBitRate, "maxBitRate") + maxBitRate = utils.MinInt(c.mf.BitRate, maxBitRate) + + beego.Debug("Streaming file", maxBitRate, ":", c.mf.Path) + beego.Debug("Bitrate", c.mf.BitRate, "MaxBitRate", maxBitRate) + + if maxBitRate > 0 { + c.Ctx.Output.Header("Content-Length", strconv.Itoa(c.mf.Duration*maxBitRate*1000/8)) + } + c.Ctx.Output.Header("Content-Type", "audio/mpeg") + c.Ctx.Output.Header("Expires", "0") + c.Ctx.Output.Header("Cache-Control", "must-revalidate") + c.Ctx.Output.Header("Pragma", "public") + + err := stream.Stream(c.mf.Path, c.mf.BitRate, maxBitRate, createFlusher(c.Ctx.ResponseWriter)) + if err != nil { + beego.Error("Error streaming file id", c.id, ":", err) + } + + beego.Debug("Finished streaming of", c.mf.Path) +} + +func (c *StreamController) Download() { + beego.Debug("Sending file", c.mf.Path) + + stream.Stream(c.mf.Path, 0, 0, c.Ctx.ResponseWriter) + + beego.Debug("Finished sending", c.mf.Path) } diff --git a/bin/fmt.sh b/bin/fmt.sh new file mode 100755 index 000000000..8c9b3a23f --- /dev/null +++ b/bin/fmt.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +gofiles=$(git diff --name-only --diff-filter=ACM | grep '.go$') +[ -z "$gofiles" ] && exit 0 + +unformatted=$(gofmt -l $gofiles) +[ -z "$unformatted" ] && exit 0 + +for f in $unformatted; do + go fmt "$f" +done \ No newline at end of file diff --git a/conf/app.conf b/conf/app.conf index 41f5c3203..64e1b7a43 100644 --- a/conf/app.conf +++ b/conf/app.conf @@ -12,7 +12,8 @@ indexGroups=A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) musicFolder=./iTunes1.xml user=deluan password=wordpass -dbPath = ./devDb +dbPath=./devDb +downsampleCommand=ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 - [dev] disableValidation = true @@ -25,3 +26,4 @@ user=deluan password=wordpass dbPath = /tmp/testDb musicFolder=./tests/itunes-library.xml +downsampleCommand=ffmpeg -i %s -b:a %bk mp3 - diff --git a/conf/router.go b/conf/router.go index f58b2fc25..48b436ab7 100644 --- a/conf/router.go +++ b/conf/router.go @@ -22,8 +22,8 @@ func mapEndpoints() { beego.NSRouter("/getIndexes.view", &api.GetIndexesController{}, "*:Get"), beego.NSRouter("/getMusicDirectory.view", &api.GetMusicDirectoryController{}, "*:Get"), beego.NSRouter("/getCoverArt.view", &api.GetCoverArtController{}, "*:Get"), - beego.NSRouter("/stream.view", &api.StreamController{}, "*:Get"), - beego.NSRouter("/download.view", &api.StreamController{}, "*:Get"), + beego.NSRouter("/stream.view", &api.StreamController{}, "*:Stream"), + beego.NSRouter("/download.view", &api.StreamController{}, "*:Download"), beego.NSRouter("/getUser.view", &api.UsersController{}, "*:GetUser"), beego.NSRouter("/getAlbumList.view", &api.GetAlbumListController{}, "*:Get"), ) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 3be64c38d..217c5bbeb 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -21,7 +21,14 @@ func (r *mediaFileRepository) Put(m *domain.MediaFile) error { func (r *mediaFileRepository) Get(id string) (*domain.MediaFile, error) { m, err := r.readEntity(id) - return m.(*domain.MediaFile), err + if err != nil { + return nil, err + } + mf := m.(*domain.MediaFile) + if mf.Id != id { + return nil, nil + } + return mf, nil } func (r *mediaFileRepository) FindByAlbum(albumId string) (domain.MediaFiles, error) { @@ -31,4 +38,4 @@ func (r *mediaFileRepository) FindByAlbum(albumId string) (domain.MediaFiles, er return mfs, err } -var _ domain.MediaFileRepository = (*mediaFileRepository)(nil) \ No newline at end of file +var _ domain.MediaFileRepository = (*mediaFileRepository)(nil) diff --git a/stream/downsampling.go b/stream/downsampling.go new file mode 100644 index 000000000..4492c3575 --- /dev/null +++ b/stream/downsampling.go @@ -0,0 +1,47 @@ +package stream + +import ( + "github.com/astaxie/beego" + "io" + "os" + "os/exec" + "strconv" + "strings" +) + +func Stream(path string, bitRate int, maxBitRate int, w io.Writer) error { + if maxBitRate > 0 && bitRate > maxBitRate { + cmdLine, args := createDownsamplingCommand(path, maxBitRate) + cmd := exec.Command(cmdLine, args...) + beego.Debug("Executing cmd:", cmdLine, args) + + cmd.Stdout = w + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + beego.Error("Error executing", cmdLine, ":", err) + } + return err + } else { + f, err := os.Open(path) + if err != nil { + beego.Error("Error opening file", path, ":", err) + return err + } + _, err = io.Copy(w, f) + return err + } +} + +func createDownsamplingCommand(path string, maxBitRate int) (string, []string) { + cmd := beego.AppConfig.String("downsampleCommand") + + split := strings.Split(cmd, " ") + for i, s := range split { + s = strings.Replace(s, "%s", path, -1) + s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1) + split[i] = s + } + + return split[0], split[1:len(split)] +} diff --git a/stream/downsampling_test.go b/stream/downsampling_test.go new file mode 100644 index 000000000..9dc5103bf --- /dev/null +++ b/stream/downsampling_test.go @@ -0,0 +1,29 @@ +package stream + +import ( + . "github.com/deluan/gosonic/tests" + . "github.com/smartystreets/goconvey/convey" + "testing" +) + +func TestDownsampling(t *testing.T) { + + Init(t, false) + + Convey("Subject: createDownsamplingCommand", t, func() { + + Convey("It should create a valid command line", func() { + cmd, args := createDownsamplingCommand("/music library/file.mp3", 128) + + So(cmd, ShouldEqual, "ffmpeg") + So(args[0], ShouldEqual, "-i") + So(args[1], ShouldEqual, "/music library/file.mp3") + So(args[2], ShouldEqual, "-b:a") + So(args[3], ShouldEqual, "128k") + So(args[4], ShouldEqual, "mp3") + So(args[5], ShouldEqual, "-") + }) + + }) + +} diff --git a/tests/mocks/mock_mediafile_repo.go b/tests/mocks/mock_mediafile_repo.go index 43ec25e1f..a2d06de79 100644 --- a/tests/mocks/mock_mediafile_repo.go +++ b/tests/mocks/mock_mediafile_repo.go @@ -46,9 +46,6 @@ func (m *MockMediaFile) Get(id string) (*domain.MediaFile, error) { return nil, errors.New("Error!") } mf := m.data[id] - if mf == nil { - mf = &domain.MediaFile{} - } return mf, nil } diff --git a/utils/math.go b/utils/math.go new file mode 100644 index 000000000..8e60f59dd --- /dev/null +++ b/utils/math.go @@ -0,0 +1,15 @@ +package utils + +func MinInt(x, y int) int { + if x < y { + return x + } + return y +} + +func MaxInt(x, y int) int { + if x > y { + return x + } + return y +}