diff --git a/go.mod b/go.mod index 622c8dcd..3873d78e 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.0 github.com/faiface/beep v1.1.0 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.3.0 // indirect github.com/gorilla/mux v1.8.0 github.com/gorilla/securecookie v1.1.1 @@ -26,7 +27,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/lib/pq v1.3.0 // indirect github.com/matryer/is v1.4.0 - github.com/mattn/go-sqlite3 v1.14.11 // indirect + github.com/mattn/go-sqlite3 v1.14.11 github.com/mewkiz/pkg v0.0.0-20211102230744-16a6ce8f1b77 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mmcdole/gofeed v1.1.3 @@ -40,7 +41,7 @@ require ( github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 github.com/stretchr/testify v1.7.0 // indirect golang.org/x/crypto v0.0.0-20220209155544-dad33157f4bf // indirect - golang.org/x/exp/shiny v0.0.0-20220209042442-160e291fcf24 // indirect + golang.org/x/exp/shiny v0.0.0-20220407100705-7b9b53b0aca4 // indirect golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect golang.org/x/mobile v0.0.0-20220112015953-858099ff7816 // indirect golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect diff --git a/go.sum b/go.sum index 6c9ff376..c1410d33 100644 --- a/go.sum +++ b/go.sum @@ -22,7 +22,6 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= @@ -50,6 +49,8 @@ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2V github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= @@ -76,6 +77,7 @@ github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lTo github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jezek/xgb v1.0.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= @@ -170,6 +172,8 @@ golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMD golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/exp/shiny v0.0.0-20220209042442-160e291fcf24 h1:jn6Q9FOmCn1Kk7ec3Qm9lfygAr7dv8J1YfEx6RQcRJQ= golang.org/x/exp/shiny v0.0.0-20220209042442-160e291fcf24/go.mod h1:NtXcNtv5Wu0zUbBl574y/D5MMZvnQnV3sgjZxbs64Jo= +golang.org/x/exp/shiny v0.0.0-20220407100705-7b9b53b0aca4 h1:ywNGLBFk8tKaiu+GYZeoXWzrFoJ/a1LHYKy1lb3R9cM= +golang.org/x/exp/shiny v0.0.0-20220407100705-7b9b53b0aca4/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8= golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index 7551c36d..007cde4d 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -10,10 +10,10 @@ import ( "github.com/mmcdole/gofeed" "go.senan.xyz/gonic/server/db" - "go.senan.xyz/gonic/server/encode" "go.senan.xyz/gonic/server/scanner" "go.senan.xyz/gonic/server/scrobble/lastfm" "go.senan.xyz/gonic/server/scrobble/listenbrainz" + "go.senan.xyz/gonic/server/transcode" ) func doScan(scanner *scanner.Scanner, opts scanner.ScanOptions) { @@ -67,7 +67,7 @@ func (c *Controller) ServeHome(r *http.Request) *Response { c.DB. Where("user_id=?", user.ID). Find(&data.TranscodePreferences) - for profile := range encode.Profiles() { + for profile := range transcode.UserProfiles { data.TranscodeProfiles = append(data.TranscodeProfiles, profile) } // podcasts box diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index b94ed80a..24824eb3 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -15,6 +15,7 @@ import ( "go.senan.xyz/gonic/server/jukebox" "go.senan.xyz/gonic/server/podcasts" "go.senan.xyz/gonic/server/scrobble" + "go.senan.xyz/gonic/server/transcode" ) type CtxKey int @@ -34,6 +35,7 @@ type Controller struct { Jukebox *jukebox.Jukebox Scrobblers []scrobble.Scrobbler Podcasts *podcasts.Podcasts + Transcoder transcode.Transcoder } type metaResponse struct { diff --git a/server/ctrlsubsonic/ctrl_test.go b/server/ctrlsubsonic/ctrl_test.go index 469d4af0..afd17a36 100644 --- a/server/ctrlsubsonic/ctrl_test.go +++ b/server/ctrlsubsonic/ctrl_test.go @@ -18,12 +18,22 @@ import ( "go.senan.xyz/gonic/server/ctrlbase" "go.senan.xyz/gonic/server/ctrlsubsonic/params" + "go.senan.xyz/gonic/server/db" "go.senan.xyz/gonic/server/mockfs" + "go.senan.xyz/gonic/server/transcode" ) -var ( - testDataDir = "testdata" - testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])") +var testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])") + +const ( + mockUsername = "admin" + mockPassword = "admin" + mockClientName = "test" +) + +const ( + audioPath5s = "testdata/audio/5s.flac" //nolint:deadcode,varcheck + audioPath10s = "testdata/audio/10s.flac" //nolint:deadcode,varcheck ) type queryCase struct { @@ -37,22 +47,43 @@ func makeGoldenPath(test string) string { snake := testCamelExpr.ReplaceAllString(test, "${1}_${2}") lower := strings.ToLower(snake) relPath := strings.ReplaceAll(lower, "/", "_") - return path.Join(testDataDir, relPath) + return path.Join("testdata", relPath) } func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request) { // ensure the handlers give us json query.Add("f", "json") + query.Add("u", mockUsername) + query.Add("p", mockPassword) + query.Add("v", "1") + query.Add("c", mockClientName) // request from the handler in question req, _ := http.NewRequest("", "", nil) req.URL.RawQuery = query.Encode() - subParams := params.New(req) - withParams := context.WithValue(req.Context(), CtxParams, subParams) + ctx := req.Context() + ctx = context.WithValue(ctx, CtxParams, params.New(req)) + ctx = context.WithValue(ctx, CtxUser, &db.User{}) + req = req.WithContext(ctx) rr := httptest.NewRecorder() - req = req.WithContext(withParams) return rr, req } +func serveRaw(t *testing.T, contr *Controller, h handlerSubsonicRaw, rr *httptest.ResponseRecorder, req *http.Request) { + type middleware func(http.Handler) http.Handler + middlewares := []middleware{ + contr.WithParams, + contr.WithRequiredParams, + contr.WithUser, + } + + handler := contr.HR(h) + for _, m := range middlewares { + handler = m(handler) + } + + handler.ServeHTTP(rr, req) +} + func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []*queryCase) { t.Helper() for _, qc := range cases { @@ -96,20 +127,26 @@ func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []* } } -func makeController(t *testing.T) *Controller { return makec(t, []string{""}) } -func makeControllerRoots(t *testing.T, r []string) *Controller { return makec(t, r) } +func makeController(t *testing.T) *Controller { return makec(t, []string{""}, false) } +func makeControllerRoots(t *testing.T, r []string) *Controller { return makec(t, r, false) } +func makeControllerAudio(t *testing.T) *Controller { return makec(t, []string{""}, true) } -func makec(t *testing.T, roots []string) *Controller { +func makec(t *testing.T, roots []string, audio bool) *Controller { t.Helper() m := mockfs.NewWithDirs(t, roots) for _, root := range roots { m.AddItemsPrefixWithCovers(root) + if !audio { + continue + } + m.SetRealAudio(filepath.Join(root, "artist-0/album-0/track-0.flac"), 10, audioPath10s) + m.SetRealAudio(filepath.Join(root, "artist-0/album-0/track-1.flac"), 10, audioPath10s) + m.SetRealAudio(filepath.Join(root, "artist-0/album-0/track-2.flac"), 10, audioPath10s) } m.ScanAndClean() m.ResetDates() - m.LogAlbums() var absRoots []string for _, root := range roots { @@ -117,7 +154,13 @@ func makec(t *testing.T, roots []string) *Controller { } base := &ctrlbase.Controller{DB: m.DB()} - return &Controller{Controller: base, MusicPaths: absRoots} + contr := &Controller{ + Controller: base, + MusicPaths: absRoots, + Transcoder: transcode.NewFFmpegTranscoder(), + } + + return contr } func TestMain(m *testing.M) { diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index f50ae26e..1bcb0a8d 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -3,7 +3,6 @@ package ctrlsubsonic import ( "errors" "fmt" - "io" "log" "net/http" "os" @@ -13,12 +12,13 @@ import ( "github.com/disintegration/imaging" "github.com/jinzhu/gorm" + "go.senan.xyz/gonic/iout" + "go.senan.xyz/gonic/server/ctrlsubsonic/httprange" "go.senan.xyz/gonic/server/ctrlsubsonic/params" "go.senan.xyz/gonic/server/ctrlsubsonic/spec" "go.senan.xyz/gonic/server/ctrlsubsonic/specid" "go.senan.xyz/gonic/server/db" - "go.senan.xyz/gonic/server/encode" - "go.senan.xyz/gonic/server/mime" + "go.senan.xyz/gonic/server/transcode" ) // "raw" handlers are ones that don't always return a spec response. @@ -36,7 +36,7 @@ func streamGetTransPref(dbc *db.DB, userID int, client string) (*db.TranscodePre First(&pref). Error if errors.Is(err, gorm.ErrRecordNotFound) { - return &pref, nil + return nil, nil } if err != nil { return nil, fmt.Errorf("find transcode preference: %w", err) @@ -242,7 +242,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R if err != nil { return spec.NewError(10, "please provide an `id` parameter") } - var audioFile db.AudioFile + var file db.AudioFile var audioPath string switch id.Type { case specid.Track: @@ -250,103 +250,78 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R if err != nil { return spec.NewError(70, "track with id `%s` was not found", id) } - audioFile = track + file = track audioPath = path.Join(track.AbsPath()) case specid.PodcastEpisode: podcast, err := streamGetPodcast(c.DB, id.Value) if err != nil { return spec.NewError(70, "podcast with id `%s` was not found", id) } - audioFile = podcast + file = podcast audioPath = path.Join(c.PodcastsPath, podcast.Path) default: return spec.NewError(70, "media type of `%s` was not found", id.Type) } user := r.Context().Value(CtxUser).(*db.User) - if track, ok := audioFile.(*db.Track); ok && track.Album != nil { + if track, ok := file.(*db.Track); ok && track.Album != nil { defer func() { if err := streamUpdateStats(c.DB, user.ID, track.Album.ID, time.Now()); err != nil { - log.Printf("error updating listen stats: %v", err) + log.Printf("error updating status: %v", err) } }() } - pref, err := streamGetTransPref(c.DB, user.ID, params.GetOr("c", "")) - if err != nil { - return spec.NewError(0, "failed to get transcode stream preference: %v", err) + if format, _ := params.Get("format"); format == "raw" { + http.ServeFile(w, r, audioPath) + return nil } - onInvalidProfile := func() error { - log.Printf("serving raw `%s`\n", audioFile.AudioFilename()) - w.Header().Set("Content-Type", audioFile.MIME()) + pref, err := streamGetTransPref(c.DB, user.ID, params.GetOr("c", "")) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return spec.NewError(0, "couldn't find transcode preference: %v", err) + } + if pref == nil { http.ServeFile(w, r, audioPath) return nil } - onCacheHit := func(profile encode.Profile, path string) error { - log.Printf("serving transcode `%s`: cache [%s/%dk] hit!\n", - audioFile.AudioFilename(), profile.Format, profile.Bitrate) - cacheMime, _ := mime.FromExtension(profile.Format) - w.Header().Set("Content-Type", cacheMime) - cacheFile, err := os.Stat(path) - if err != nil { - return fmt.Errorf("failed to stat cache file `%s`: %w", path, err) - } - contentLength := fmt.Sprintf("%d", cacheFile.Size()) - w.Header().Set("Content-Length", contentLength) - http.ServeFile(w, r, path) - return nil + profile, ok := transcode.UserProfiles[pref.Profile] + if !ok { + return spec.NewError(0, "unknown transcode user profile %q", pref.Profile) } - onCacheMiss := func(profile encode.Profile) (io.Writer, error) { - log.Printf("serving transcode `%s`: cache [%s/%dk] miss!\n", - audioFile.AudioFilename(), profile.Format, profile.Bitrate) - encodeMime, _ := mime.FromExtension(profile.Format) - w.Header().Set("Content-Type", encodeMime) - return w, nil - } - encodeOptions := encode.Options{ - TrackPath: audioPath, - TrackBitrate: audioFile.AudioBitrate(), - CachePath: c.CachePath, - ProfileName: pref.Profile, - PreferredBitrate: params.GetOrInt("maxBitRate", 0), - OnInvalidProfile: onInvalidProfile, - OnCacheHit: onCacheHit, - OnCacheMiss: onCacheMiss, - } - if err := encode.Encode(encodeOptions); err != nil { - log.Printf("serving transcode `%s`: error: %v\n", audioFile.AudioFilename(), err) + if max, _ := params.GetInt("maxBitRate"); max > 0 && int(profile.BitRate()) > max { + profile = transcode.WithBitrate(profile, transcode.BitRate(max)) } - return nil -} -func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec.Response { - params := r.Context().Value(CtxParams).(params.Params) - id, err := params.GetID("id") + log.Printf("trancoding to %q with max bitrate %dk", profile.MIME(), profile.BitRate()) + + transcodeReader, err := c.Transcoder.Transcode(r.Context(), profile, audioPath) if err != nil { - return spec.NewError(10, "please provide an `id` parameter") + return spec.NewError(0, "error transcoding: %v", err) } - var filePath string - var audioFile db.AudioFile - switch id.Type { - case specid.Track: - track, _ := streamGetTrack(c.DB, id.Value) - audioFile = track - filePath = track.AbsPath() - if err != nil { - return spec.NewError(70, "track with id `%s` was not found", id) - } - case specid.PodcastEpisode: - podcast, err := streamGetPodcast(c.DB, id.Value) - audioFile = podcast - filePath = path.Join(c.PodcastsPath, podcast.Path) - if err != nil { - return spec.NewError(70, "podcast with id `%s` was not found", id) - } + defer transcodeReader.Close() + + length := transcode.GuessExpectedSize(profile, time.Duration(file.AudioLength())*time.Second) // TODO: if there's no duration? + rreq, err := httprange.Parse(r.Header.Get("Range"), length) + if err != nil { + return spec.NewError(0, "error parsing range: %v", err) + } + + w.Header().Set("Content-Type", profile.MIME()) + w.Header().Set("Content-Length", fmt.Sprintf("%d", rreq.Length)) + w.Header().Set("Accept-Ranges", string(httprange.UnitBytes)) + + if rreq.Partial { + w.WriteHeader(http.StatusPartialContent) + w.Header().Set("Content-Range", fmt.Sprintf("%s %d-%d/%d", httprange.UnitBytes, rreq.Start, rreq.End, length)) + } + + if err := iout.CopyRange(w, transcodeReader, int64(rreq.Start), int64(rreq.Length)); err != nil { + log.Printf("error writing transcoded data: %v", err) + } + if f, ok := w.(http.Flusher); ok { + f.Flush() } - log.Printf("serving raw `%s`\n", audioFile.AudioFilename()) - w.Header().Set("Content-Type", audioFile.MIME()) - http.ServeFile(w, r, filePath) return nil } diff --git a/server/ctrlsubsonic/handlers_raw_test.go b/server/ctrlsubsonic/handlers_raw_test.go new file mode 100644 index 00000000..f79e85ed --- /dev/null +++ b/server/ctrlsubsonic/handlers_raw_test.go @@ -0,0 +1,152 @@ +package ctrlsubsonic + +import ( + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "os/exec" + "strconv" + "testing" + "time" + + "github.com/matryer/is" + "go.senan.xyz/gonic/server/db" + "go.senan.xyz/gonic/server/transcode" +) + +func TestServeStreamRaw(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skipf("no ffmpeg in $PATH") + } + + is := is.New(t) + contr := makeControllerAudio(t) + + statFlac := stat(t, audioPath10s) + + rr, req := makeHTTPMock(url.Values{"id": {"tr-1"}}) + serveRaw(t, contr, contr.ServeStream, rr, req) + + is.Equal(rr.Code, http.StatusOK) + is.Equal(rr.Header().Get("content-type"), "audio/flac") + is.Equal(atoi(t, rr.Header().Get("content-length")), int(statFlac.Size())) + is.Equal(atoi(t, rr.Header().Get("content-length")), rr.Body.Len()) +} + +func TestServeStreamOpus(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skipf("no ffmpeg in $PATH") + } + + is := is.New(t) + contr := makeControllerAudio(t) + + var user db.User + is.NoErr(contr.DB.Where("name=?", mockUsername).Find(&user).Error) + is.NoErr(contr.DB.Create(&db.TranscodePreference{UserID: user.ID, Client: mockClientName, Profile: "opus"}).Error) + + rr, req := makeHTTPMock(url.Values{"id": {"tr-1"}}) + serveRaw(t, contr, contr.ServeStream, rr, req) + + is.Equal(rr.Code, http.StatusOK) + is.Equal(rr.Header().Get("content-type"), "audio/ogg") + is.Equal(atoi(t, rr.Header().Get("content-length")), transcode.GuessExpectedSize(transcode.Opus, 10*time.Second)) + is.Equal(atoi(t, rr.Header().Get("content-length")), rr.Body.Len()) +} + +func TestServeStreamOpusMaxBitrate(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skipf("no ffmpeg in $PATH") + } + + is := is.New(t) + contr := makeControllerAudio(t) + + var user db.User + is.NoErr(contr.DB.Where("name=?", mockUsername).Find(&user).Error) + is.NoErr(contr.DB.Create(&db.TranscodePreference{UserID: user.ID, Client: mockClientName, Profile: "opus"}).Error) + + const bitrate = 5 + + rr, req := makeHTTPMock(url.Values{"id": {"tr-1"}, "maxBitRate": {strconv.Itoa(bitrate)}}) + serveRaw(t, contr, contr.ServeStream, rr, req) + + profile := transcode.WithBitrate(transcode.Opus, transcode.BitRate(bitrate)) + expectedLength := transcode.GuessExpectedSize(profile, 10*time.Second) + + is.Equal(rr.Code, http.StatusOK) + is.Equal(rr.Header().Get("content-type"), "audio/ogg") + is.Equal(atoi(t, rr.Header().Get("content-length")), expectedLength) + is.Equal(atoi(t, rr.Header().Get("content-length")), rr.Body.Len()) +} + +func TestServeStreamMP3Range(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skipf("no ffmpeg in $PATH") + } + + is := is.New(t) + contr := makeControllerAudio(t) + + var user db.User + is.NoErr(contr.DB.Where("name=?", mockUsername).Find(&user).Error) + is.NoErr(contr.DB.Create(&db.TranscodePreference{UserID: user.ID, Client: mockClientName, Profile: "mp3"}).Error) + + var totalBytes []byte + { + rr, req := makeHTTPMock(url.Values{"id": {"tr-1"}}) + serveRaw(t, contr, contr.ServeStream, rr, req) + is.Equal(rr.Code, http.StatusOK) + is.Equal(rr.Header().Get("content-type"), "audio/mpeg") + totalBytes = rr.Body.Bytes() + } + + const chunkSize = 2 << 16 + + var bytes []byte + for i := 0; i < len(totalBytes); i += chunkSize { + rr, req := makeHTTPMock(url.Values{"id": {"tr-1"}}) + req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", i, min(i+chunkSize, len(totalBytes))-1)) + t.Log(req.Header.Get("range")) + serveRaw(t, contr, contr.ServeStream, rr, req) + is.Equal(rr.Code, http.StatusPartialContent) + is.Equal(rr.Header().Get("content-type"), "audio/mpeg") + is.True(atoi(t, rr.Header().Get("content-length")) == chunkSize || atoi(t, rr.Header().Get("content-length")) == len(totalBytes)%chunkSize) + is.Equal(atoi(t, rr.Header().Get("content-length")), rr.Body.Len()) + bytes = append(bytes, rr.Body.Bytes()...) + } + + is.Equal(len(totalBytes), len(bytes)) + is.Equal(totalBytes, bytes) +} + +func stat(t *testing.T, path string) fs.FileInfo { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %q: %v", path, err) + } + return info +} + +func atoi(t *testing.T, in string) int { + t.Helper() + i, err := strconv.Atoi(in) + if err != nil { + t.Fatalf("atoi %q: %v", in, err) + } + return i +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/server/ctrlsubsonic/httprange/httprange.go b/server/ctrlsubsonic/httprange/httprange.go new file mode 100644 index 00000000..0291f1b8 --- /dev/null +++ b/server/ctrlsubsonic/httprange/httprange.go @@ -0,0 +1,59 @@ +package httprange + +import ( + "fmt" + "regexp" + "strconv" +) + +type Unit string + +const ( + UnitBytes Unit = "bytes" +) + +//nolint:gochecknoglobals +var ( + reg = regexp.MustCompile(`^(?P\w+)=(?P(?:\d+)?)\s*-\s*(?P(?:\d+)?)$`) + unit = reg.SubexpIndex("unit") + start = reg.SubexpIndex("start") + end = reg.SubexpIndex("end") +) + +var ( + ErrInvalidRange = fmt.Errorf("invalid range") + ErrUnknownUnit = fmt.Errorf("unknown range") +) + +type Range struct { + Start, End, Length int // bytes + Partial bool +} + +func Parse(in string, fullLength int) (Range, error) { + parts := reg.FindStringSubmatch(in) + if len(parts)-1 != reg.NumSubexp() { + return Range{0, fullLength - 1, fullLength, false}, nil + } + + switch unit := parts[unit]; Unit(unit) { + case UnitBytes: + default: + return Range{}, fmt.Errorf("%q: %w", unit, ErrUnknownUnit) + } + + start, _ := strconv.Atoi(parts[start]) + end, _ := strconv.Atoi(parts[end]) + length := fullLength + partial := false + + switch { + case end > 0 && end < length: + length = end - start + 1 + partial = true + case end == 0 && length > 0: + end = length - 1 + } + + return Range{start, end, length, partial}, nil +} diff --git a/server/ctrlsubsonic/httprange/httprange_test.go b/server/ctrlsubsonic/httprange/httprange_test.go new file mode 100644 index 00000000..9e0c385f --- /dev/null +++ b/server/ctrlsubsonic/httprange/httprange_test.go @@ -0,0 +1,30 @@ +package httprange_test + +import ( + "testing" + + "github.com/matryer/is" + "go.senan.xyz/gonic/server/ctrlsubsonic/httprange" +) + +func TestParse(t *testing.T) { + is := is.New(t) + + full := func(start, end, length int) httprange.Range { + return httprange.Range{Start: start, End: end, Length: length} + } + partial := func(start, end, length int) httprange.Range { + return httprange.Range{Start: start, End: end, Length: length, Partial: true} + } + parse := func(in string, length int) httprange.Range { + is.Helper() + rrange, err := httprange.Parse(in, length) + is.NoErr(err) + return rrange + } + + is.Equal(parse("bytes=0-0", 0), full(0, 0, 0)) + is.Equal(parse("bytes=0-", 10), full(0, 9, 10)) + is.Equal(parse("bytes=0-49", 50), partial(0, 49, 50)) + is.Equal(parse("bytes=50-99", 100), partial(50, 99, 50)) +} diff --git a/server/ctrlsubsonic/testdata/audio/10s.flac b/server/ctrlsubsonic/testdata/audio/10s.flac new file mode 100644 index 00000000..97261447 Binary files /dev/null and b/server/ctrlsubsonic/testdata/audio/10s.flac differ diff --git a/server/ctrlsubsonic/testdata/audio/5s.flac b/server/ctrlsubsonic/testdata/audio/5s.flac new file mode 100644 index 00000000..75b894ec Binary files /dev/null and b/server/ctrlsubsonic/testdata/audio/5s.flac differ diff --git a/server/db/model.go b/server/db/model.go index 814b5e49..09ff975a 100644 --- a/server/db/model.go +++ b/server/db/model.go @@ -72,10 +72,11 @@ type Genre struct { // AudioFile is used to avoid some duplication in handlers_raw.go // between Track and Podcast type AudioFile interface { - AudioFilename() string Ext() string MIME() string + AudioFilename() string AudioBitrate() int + AudioLength() int } type Track struct { @@ -100,6 +101,9 @@ type Track struct { TagBrainzID string `sql:"default: null"` } +func (t *Track) AudioLength() int { return t.Length } +func (t *Track) AudioBitrate() int { return t.Bitrate } + func (t *Track) SID() *specid.ID { return &specid.ID{Type: specid.Track, Value: t.ID} } @@ -124,10 +128,6 @@ func (t *Track) AudioFilename() string { return t.Filename } -func (t *Track) AudioBitrate() int { - return t.Bitrate -} - func (t *Track) MIME() string { v, _ := mime.FromExtension(t.Ext()) return v @@ -364,6 +364,9 @@ type PodcastEpisode struct { Error string } +func (pe *PodcastEpisode) AudioLength() int { return pe.Length } +func (pe *PodcastEpisode) AudioBitrate() int { return pe.Bitrate } + func (pe *PodcastEpisode) SID() *specid.ID { return &specid.ID{Type: specid.PodcastEpisode, Value: pe.ID} } @@ -385,10 +388,6 @@ func (pe *PodcastEpisode) MIME() string { return v } -func (pe *PodcastEpisode) AudioBitrate() int { - return pe.Bitrate -} - type Bookmark struct { ID int `gorm:"primary_key"` User *User diff --git a/server/encode/encode.go b/server/encode/encode.go deleted file mode 100644 index 6978a663..00000000 --- a/server/encode/encode.go +++ /dev/null @@ -1,284 +0,0 @@ -// author: spijet (https://github.com/spijet/) - -package encode - -import ( - "fmt" - "io" - "log" - "net/http" - "os" - "os/exec" - "path" - "strings" - - "github.com/cespare/xxhash" -) - -const ( - buffLen = 4096 - ffmpeg = "ffmpeg" -) - -type replayGain int - -const ( - rgForce replayGain = iota - rgHigh -) - -type Profile struct { - Format string - Bitrate int - - options []string - replayGain replayGain - upsample 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": { - Format: "mp3", - Bitrate: 128, - options: []string{"-c:a", "libmp3lame"}, - }, - "mp3_rg": { - Format: "mp3", - Bitrate: 128, - options: []string{"-c:a", "libmp3lame"}, - replayGain: rgForce, - }, - "opus": { - Format: "opus", - Bitrate: 96, - options: []string{"-c:a", "libopus", "-vbr", "on"}, - }, - "opus_rg": { - Format: "opus", - Bitrate: 96, - options: []string{"-c:a", "libopus", "-vbr", "on"}, - replayGain: rgForce, - }, - "opus_car": { - Format: "opus", - Bitrate: 96, - options: []string{"-c:a", "libopus", "-vbr", "on"}, - replayGain: rgHigh, - upsample: true, - }, - } -} - -// 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 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) - // start copying! - if _, err := io.Copy(w, pipeReader); err != nil { - log.Printf("error while writing encoded output: %s\n", err) - } -} - -// copy command output to http response manually with a buffer (should reduce ttfb) -//nolint:deadcode,unused // function may be switched later -func cmdOutputWrite(out, cache io.Writer, pipeReader io.ReadCloser) { - buffer := make([]byte, buffLen) - for { - n, err := pipeReader.Read(buffer) - if err != nil { - _ = pipeReader.Close() - break - } - data := buffer[0:n] - if _, err := out.Write(data); err != nil { - log.Printf("error while writing HTTP response: %s\n", err) - } - if _, err := cache.Write(data); err != nil { - log.Printf("error while writing cache file: %s\n", err) - } - if f, ok := out.(http.Flusher); ok { - f.Flush() - } - // reset buffer - for i := 0; i < n; i++ { - buffer[i] = 0 - } - } -} - -// pre-format the ffmpeg command with needed options -func ffmpegCommand(filePath string, profile Profile) (*exec.Cmd, error) { - args := []string{ - "-v", "0", - "-i", filePath, - "-map", "0:a:0", - "-vn", - "-b:a", fmt.Sprintf("%dk", profile.Bitrate), - } - args = append(args, profile.options...) - - var aFilters []string - var aMetadata []string - - // opus always forces output to 48kHz sampling rate, but we can still use upsampling - // to increase RG and alimiter's peak limiting precision, which is desirable in some - // cases. ffmpeg's `soxr` resampler is quite fast on x86-64: it takes around 5 seconds - // on my Ryzen 3600 to transcode an 8-minute FLAC with 2x upsample and RG applied. - // -- @spijet - if profile.upsample { - aFilters = append(aFilters, "aresample=96000:resampler=soxr") - } - - switch profile.replayGain { - case rgForce: - aFilters = append(aFilters, ffmpegPreamp(6)...) - aMetadata = append(aMetadata, ffmpegStripRG()...) - case rgHigh: - // this baseline gain results in final track being +3~5dB louder - // than Foobar2000's default ReplayGain target volume. - // this makes it easier to listen to music in a car, where all other - // sources are usually ten thousand times louder than RG-adjusted music. - // -- @spijet - aFilters = append(aFilters, ffmpegPreamp(15)...) - } - - if len(aFilters) > 0 { - args = append(args, "-af", strings.Join(aFilters, ", ")) - } - - args = append(args, aMetadata...) - args = append(args, "-f", profile.Format, "-") - - ffmpegPath, err := exec.LookPath(ffmpeg) - if err != nil { - return nil, fmt.Errorf("find ffmpeg binary path: %w", err) - } - return exec.Command(ffmpegPath, args...), nil //nolint:gosec - // can't see a way for this be abused - // but please do let me know if you see otherwise -} - -func ffmpegPreamp(dB int) []string { - return []string{ - fmt.Sprintf("volume=replaygain=track:replaygain_preamp=%ddB:replaygain_noclip=0", dB), - "alimiter=level=disabled", - "asidedata=mode=delete:type=REPLAYGAIN", - } -} - -func ffmpegStripRG() []string { - return []string{ - "-metadata", "replaygain_album_gain=", - "-metadata", "replaygain_album_peak=", - "-metadata", "replaygain_track_gain=", - "-metadata", "replaygain_track_peak=", - "-metadata", "r128_album_gain=", - "-metadata", "r128_track_gain=", - } -} - -func encode(out io.Writer, trackPath, cachePath string, profile Profile) error { - // prepare cache part file path - cachePartPath := fmt.Sprintf("%s.part", cachePath) - // prepare the command and file descriptors - cmd, err := ffmpegCommand(trackPath, profile) - if err != nil { - return fmt.Errorf("generate ffmpeg command: %w", err) - } - pipeReader, pipeWriter := io.Pipe() - cmd.Stdout = pipeWriter - cmd.Stderr = pipeWriter - // create cache part file - cacheFile, err := os.Create(cachePartPath) - if err != nil { - return fmt.Errorf("writing to cache file %q: %v: %w", cachePath, err, err) - } - // 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 cmdOutputWrite(out, cacheFile, pipeReader) - // run ffmpeg - if err := cmd.Run(); err != nil { - return fmt.Errorf("running ffmpeg: %w", err) - } - // close all pipes and flush cache part file - _ = pipeWriter.Close() - if err := cacheFile.Sync(); err != nil { - return fmt.Errorf("flushing %q: %w", cachePath, err) - } - _ = cacheFile.Close() - // rename cache part file to mark it as valid cache file - _ = os.Rename(cachePartPath, cachePath) - return nil -} - -// 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, - ) -} - -type ( - OnInvalidProfileFunc func() error - OnCacheHitFunc func(Profile, string) error - OnCacheMissFunc func(Profile) (io.Writer, error) -) - -type Options struct { - TrackPath string - TrackBitrate int - 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() - } - switch { - case opts.PreferredBitrate != 0 && opts.PreferredBitrate >= opts.TrackBitrate: - log.Printf("not transcoding, requested bitrate larger or equal to track bitrate\n") - return opts.OnInvalidProfile() - case opts.PreferredBitrate != 0 && opts.PreferredBitrate < opts.TrackBitrate: - profile.Bitrate = opts.PreferredBitrate - log.Printf("transcoding according to client request of %dk \n", profile.Bitrate) - case opts.PreferredBitrate == 0 && profile.Bitrate >= opts.TrackBitrate: - log.Printf("not transcoding, profile bitrate larger or equal to track bitrate\n") - return opts.OnInvalidProfile() - case opts.PreferredBitrate == 0 && profile.Bitrate < opts.TrackBitrate: - log.Printf("transcoding according to transcoding profile of %dk\n", 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 -} diff --git a/server/mockfs/mockfs.go b/server/mockfs/mockfs.go index ca327204..b682851b 100644 --- a/server/mockfs/mockfs.go +++ b/server/mockfs/mockfs.go @@ -148,6 +148,22 @@ func (m *MockFS) Symlink(src, dest string) { } } +func (m *MockFS) SetRealAudio(path string, length int, audioPath string) { + abspath := filepath.Join(m.dir, path) + if err := os.Remove(abspath); err != nil { + m.t.Fatalf("remove all: %v", err) + } + wd, _ := os.Getwd() + if err := os.Symlink(filepath.Join(wd, audioPath), abspath); err != nil { + m.t.Fatalf("symlink: %v", err) + } + m.SetTags(path, func(tags *Tags) error { + tags.RawLength = length + tags.RawBitrate = 0 + return nil + }) +} + func (m *MockFS) LogItems() { m.t.Logf("\nitems") var items int @@ -337,6 +353,9 @@ type Tags struct { RawAlbum string RawAlbumArtist string RawGenre string + + RawBitrate int + RawLength int } func (m *Tags) Title() string { return m.RawTitle } @@ -348,10 +367,11 @@ func (m *Tags) AlbumBrainzID() string { return "" } func (m *Tags) Genre() string { return m.RawGenre } func (m *Tags) TrackNumber() int { return 1 } func (m *Tags) DiscNumber() int { return 1 } -func (m *Tags) Length() int { return 100 } -func (m *Tags) Bitrate() int { return 100 } func (m *Tags) Year() int { return 2021 } +func (m *Tags) Length() int { return firstInt(100, m.RawLength) } +func (m *Tags) Bitrate() int { return firstInt(100, m.RawBitrate) } + func (m *Tags) SomeAlbum() string { return first("Unknown Album", m.Album()) } func (m *Tags) SomeArtist() string { return first("Unknown Artist", m.Artist()) } func (m *Tags) SomeAlbumArtist() string { return first("Unknown Artist", m.AlbumArtist(), m.Artist()) } @@ -367,3 +387,12 @@ func first(or string, strs ...string) string { } return or } + +func firstInt(or int, ints ...int) int { + for _, int := range ints { + if int > 0 { + return int + } + } + return or +} diff --git a/server/server.go b/server/server.go index 92b7fd38..a4fd1de2 100644 --- a/server/server.go +++ b/server/server.go @@ -23,6 +23,7 @@ import ( "go.senan.xyz/gonic/server/scrobble" "go.senan.xyz/gonic/server/scrobble/lastfm" "go.senan.xyz/gonic/server/scrobble/listenbrainz" + "go.senan.xyz/gonic/server/transcode" ) type Options struct { @@ -84,6 +85,11 @@ func New(opts Options) (*Server, error) { podcast := podcasts.New(opts.DB, opts.PodcastPath, tagger) + cacheTranscoder := transcode.NewCachingTranscoder( + transcode.NewFFmpegTranscoder(), + opts.CachePath, + ) + ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast) if err != nil { return nil, fmt.Errorf("create admin controller: %w", err) @@ -97,6 +103,7 @@ func New(opts Options) (*Server, error) { Jukebox: &jukebox.Jukebox{}, Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}}, Podcasts: podcast, + Transcoder: cacheTranscoder, } setupMisc(r, base) @@ -222,9 +229,9 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.Handle("/getSimilarSongs2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSimilarSongsTwo)) // raw - r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeDownload)) r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt)) r.Handle("/stream{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream)) + r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream)) // browse by tag r.Handle("/getAlbum{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbum)) diff --git a/server/transcode/testdata/10s.mp3 b/server/transcode/testdata/10s.mp3 new file mode 100644 index 00000000..af09ea83 Binary files /dev/null and b/server/transcode/testdata/10s.mp3 differ diff --git a/server/transcode/testdata/5s.mp3 b/server/transcode/testdata/5s.mp3 new file mode 100644 index 00000000..27ba3355 Binary files /dev/null and b/server/transcode/testdata/5s.mp3 differ diff --git a/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/1e10ee326324ee7e1f371585414e7e41d5c802b4db45ebc79abe0397fb915a0c b/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/1e10ee326324ee7e1f371585414e7e41d5c802b4db45ebc79abe0397fb915a0c new file mode 100644 index 00000000..bb802b23 --- /dev/null +++ b/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/1e10ee326324ee7e1f371585414e7e41d5c802b4db45ebc79abe0397fb915a0c @@ -0,0 +1,3 @@ +go test fuzz v1 +byte('Y') +byte('\x05') diff --git a/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/3eaed9c9c8a57078033cea3337a79e1e6bfebf7e5eb7421878c911df2d78d1ac b/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/3eaed9c9c8a57078033cea3337a79e1e6bfebf7e5eb7421878c911df2d78d1ac new file mode 100644 index 00000000..526517e5 --- /dev/null +++ b/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/3eaed9c9c8a57078033cea3337a79e1e6bfebf7e5eb7421878c911df2d78d1ac @@ -0,0 +1,3 @@ +go test fuzz v1 +byte('\x15') +byte('}') diff --git a/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/ce5db7c5c16dcb48567255c77772cacc3ca86d524cbb2d3a769248625bc3edc8 b/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/ce5db7c5c16dcb48567255c77772cacc3ca86d524cbb2d3a769248625bc3edc8 new file mode 100644 index 00000000..1a9f00d4 --- /dev/null +++ b/server/transcode/testdata/fuzz/FuzzGuessExpectedSize/ce5db7c5c16dcb48567255c77772cacc3ca86d524cbb2d3a769248625bc3edc8 @@ -0,0 +1,3 @@ +go test fuzz v1 +byte('\a') +byte('\x02') diff --git a/server/transcode/transcode.go b/server/transcode/transcode.go new file mode 100644 index 00000000..f17722db --- /dev/null +++ b/server/transcode/transcode.go @@ -0,0 +1,129 @@ +// author: spijet (https://github.com/spijet/) +// author: sentriz (https://github.com/sentriz/) + +//nolint:gochecknoglobals +package transcode + +import ( + "context" + "fmt" + "io" + "os/exec" + "time" + + "github.com/google/shlex" +) + +type Transcoder interface { + Transcode(ctx context.Context, profile Profile, in string) (io.ReadCloser, error) +} + +var UserProfiles = map[string]Profile{ + "mp3": MP3, + "mp3_rg": MP3RG, + "opus_car": OpusCar, + "opus": Opus, + "opus_rg": OpusRG, +} + +// Store as simple strings, since we may let the user provide their own profiles soon +var ( + MP3 = NewProfile("audio/mpeg", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libmp3lame -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f mp3 -`) + MP3RG = NewProfile("audio/mpeg", 128, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libmp3lame -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f mp3 -`) + + // this sets a baseline gain which results in the final track being +3~5dB louder than + // Foobar2000's default ReplayGain target volume. + // this makes it easier to listen to music in a car, where all other + // sources are usually ten thousand times louder than RG-adjusted music. + // + // opus always forces output to 48kHz sampling rate, but we can still use upsampling + // to increase RG and alimiter's peak limiting precision, which is desirable in some + // cases. ffmpeg's `soxr` resampler is quite fast on x86-64: it takes around 5 seconds + // on my Ryzen 3600 to transcode an 8-minute FLAC with 2x upsample and RG applied. + // + // -- @spijet + OpusCar = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "aresample=96000:resampler=soxr, volume=replaygain=track:replaygain_preamp=15dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -f opus -`) + Opus = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`) + OpusRG = NewProfile("audio/ogg", 96, `ffmpeg -v 0 -i -ss -map 0:a:0 -vn -b:a -c:a libopus -vbr on -af "volume=replaygain=track:replaygain_preamp=6dB:replaygain_noclip=0, alimiter=level=disabled, asidedata=mode=delete:type=REPLAYGAIN" -metadata replaygain_album_gain= -metadata replaygain_album_peak= -metadata replaygain_track_gain= -metadata replaygain_track_peak= -metadata r128_album_gain= -metadata r128_track_gain= -f opus -`) + + PCM16le = NewProfile("audio/wav", 0, `ffmpeg -v 0 -i -ss -c:a pcm_s16le -ac 2 -f s16le -`) +) + +type BitRate int // kb/s + +type Profile struct { + bitrate BitRate // the default bitrate, but the user can request a different one + seek time.Duration + mime string + exec string +} + +func (p *Profile) BitRate() BitRate { return p.bitrate } +func (p *Profile) Seek() time.Duration { return p.seek } +func (p *Profile) MIME() string { return p.mime } + +func NewProfile(mime string, bitrate BitRate, exec string) Profile { + return Profile{mime: mime, bitrate: bitrate, exec: exec} +} + +func WithBitrate(p Profile, bitRate BitRate) Profile { + p.bitrate = bitRate + return p +} +func WithSeek(p Profile, seek time.Duration) Profile { + p.seek = seek + return p +} + +var ErrNoProfileParts = fmt.Errorf("not enough profile parts") + +func parseProfile(profile Profile, in string) (string, []string, error) { + parts, err := shlex.Split(profile.exec) + if err != nil { + return "", nil, fmt.Errorf("split command: %w", err) + } + if len(parts) == 0 { + return "", nil, ErrNoProfileParts + } + name, err := exec.LookPath(parts[0]) + if err != nil { + return "", nil, fmt.Errorf("find name: %w", err) + } + + var args []string + for _, p := range parts[1:] { + switch p { + case "": + args = append(args, in) + case "": + args = append(args, fmt.Sprintf("%dus", profile.Seek().Microseconds())) + case "": + args = append(args, fmt.Sprintf("%dk", profile.BitRate())) + default: + args = append(args, p) + } + } + + return name, args, nil +} + +// GuessExpectedSize guesses how big the transcoded file will be in bytes. +// Handy if we want to send a Content-Length header to the client before +// the transcode has finished. This way, clients like DSub can render their +// scrub bar and duration as the track is streaming. +// +// The estimate should overshoot a bit (2s in this case) otherwise some HTTP +// clients will shit their trousers given some unexpected bytes. +func GuessExpectedSize(profile Profile, length time.Duration) int { + if length == 0 { + return 0 + } + + bytesPerSec := int(profile.BitRate() * 1000 / 8) + + var guess int + guess += bytesPerSec * int(length.Seconds()-profile.seek.Seconds()) + guess += bytesPerSec * 2 // 2s pading + guess += 10000 // 10kb byte padding + return guess +} diff --git a/server/transcode/transcode_test.go b/server/transcode/transcode_test.go new file mode 100644 index 00000000..d53d156c --- /dev/null +++ b/server/transcode/transcode_test.go @@ -0,0 +1,47 @@ +//go:build go1.18 +// +build go1.18 + +package transcode_test + +import ( + "context" + "io" + "testing" + "time" + + "github.com/matryer/is" + "go.senan.xyz/gonic/server/transcode" +) + +// FuzzGuessExpectedSize makes sure all of our profile's estimated transcode +// file sizes are slightly bigger than the real thing. +func FuzzGuessExpectedSize(f *testing.F) { + var profiles []transcode.Profile + for _, v := range transcode.UserProfiles { + profiles = append(profiles, v) + } + + type track struct { + path string + length time.Duration + } + var tracks []track + tracks = append(tracks, track{"testdata/5s.mp3", 5 * time.Second}) + tracks = append(tracks, track{"testdata/10s.mp3", 10 * time.Second}) + + tr := transcode.NewFFmpegTranscoder() + f.Fuzz(func(t *testing.T, pseed uint8, tseed uint8) { + is := is.New(t) + profile := profiles[int(pseed)%len(profiles)] + track := tracks[int(tseed)%len(tracks)] + + sizeGuess := transcode.GuessExpectedSize(profile, track.length) + + reader, err := tr.Transcode(context.Background(), profile, track.path) + is.NoErr(err) + + actual, err := io.ReadAll(reader) + is.NoErr(err) + is.True(sizeGuess > len(actual)) + }) +} diff --git a/server/transcode/transcoder_caching.go b/server/transcode/transcoder_caching.go new file mode 100644 index 00000000..683d73c1 --- /dev/null +++ b/server/transcode/transcoder_caching.go @@ -0,0 +1,65 @@ +package transcode + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "os" + "path/filepath" + + "go.senan.xyz/gonic/iout" +) + +const perm = 0644 + +type CachingTranscoder struct { + cachePath string + transcoder Transcoder +} + +var _ Transcoder = (*CachingTranscoder)(nil) + +func NewCachingTranscoder(t Transcoder, cachePath string) *CachingTranscoder { + return &CachingTranscoder{transcoder: t, cachePath: cachePath} +} + +func (t *CachingTranscoder) Transcode(ctx context.Context, profile Profile, in string) (io.ReadCloser, error) { + if err := os.MkdirAll(t.cachePath, perm^0111); err != nil { + return nil, fmt.Errorf("make cache path: %w", err) + } + + name, args, err := parseProfile(profile, in) + if err != nil { + return nil, fmt.Errorf("split command: %w", err) + } + + key := cacheKey(name, args) + path := filepath.Join(t.cachePath, key) + + cf, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + return nil, fmt.Errorf("open cache file: %w", err) + } + if i, err := cf.Stat(); err == nil && i.Size() > 0 { + return cf, nil + } + + out, err := t.transcoder.Transcode(ctx, profile, in) + if err != nil { + return nil, fmt.Errorf("internal transcode: %w", err) + } + + return iout.NewTeeCloser(out, cf), nil +} + +func cacheKey(cmd string, args []string) string { + // the cache is invalid whenever transcode command (which includes the + // absolute filepath, bit rate args, replay gain args, etc.) changes + sum := md5.New() + _, _ = io.WriteString(sum, cmd) + for _, arg := range args { + _, _ = io.WriteString(sum, arg) + } + return fmt.Sprintf("%x", sum.Sum(nil)) +} diff --git a/server/transcode/transcoder_ffmpeg.go b/server/transcode/transcoder_ffmpeg.go new file mode 100644 index 00000000..04f9df82 --- /dev/null +++ b/server/transcode/transcoder_ffmpeg.go @@ -0,0 +1,39 @@ +package transcode + +import ( + "context" + "fmt" + "io" + "os/exec" +) + +type FFmpegTranscoder struct{} + +var _ Transcoder = (*FFmpegTranscoder)(nil) + +func NewFFmpegTranscoder() *FFmpegTranscoder { + return &FFmpegTranscoder{} +} + +var ErrFFmpegExit = fmt.Errorf("ffmpeg exited with non 0 status code") + +func (*FFmpegTranscoder) Transcode(ctx context.Context, profile Profile, in string) (io.ReadCloser, error) { + name, args, err := parseProfile(profile, in) + if err != nil { + return nil, fmt.Errorf("split command: %w", err) + } + + preader, pwriter := io.Pipe() + + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = pwriter + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("starting cmd: %w", err) + } + + go func() { + _ = pwriter.CloseWithError(cmd.Wait()) + }() + + return preader, nil +} diff --git a/server/transcode/transcoder_none.go b/server/transcode/transcoder_none.go new file mode 100644 index 00000000..6582b193 --- /dev/null +++ b/server/transcode/transcoder_none.go @@ -0,0 +1,19 @@ +package transcode + +import ( + "context" + "io" + "os" +) + +type NoneTranscoder struct{} + +var _ Transcoder = (*NoneTranscoder)(nil) + +func NewNoneTranscoder() *NoneTranscoder { + return &NoneTranscoder{} +} + +func (*NoneTranscoder) Transcode(ctx context.Context, _ Profile, in string) (io.ReadCloser, error) { + return os.Open(in) +}