feat(transcode): add a generic transcoding package for encoding/decoding/caching
This commit is contained in:
5
go.mod
5
go.mod
@@ -11,6 +11,7 @@ require (
|
|||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/dustin/go-humanize v1.0.0
|
github.com/dustin/go-humanize v1.0.0
|
||||||
github.com/faiface/beep v1.1.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/google/uuid v1.3.0 // indirect
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/securecookie v1.1.1
|
github.com/gorilla/securecookie v1.1.1
|
||||||
@@ -26,7 +27,7 @@ require (
|
|||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/lib/pq v1.3.0 // indirect
|
github.com/lib/pq v1.3.0 // indirect
|
||||||
github.com/matryer/is v1.4.0
|
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/mewkiz/pkg v0.0.0-20211102230744-16a6ce8f1b77 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/mmcdole/gofeed v1.1.3
|
github.com/mmcdole/gofeed v1.1.3
|
||||||
@@ -40,7 +41,7 @@ require (
|
|||||||
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981
|
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981
|
||||||
github.com/stretchr/testify v1.7.0 // indirect
|
github.com/stretchr/testify v1.7.0 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20220209155544-dad33157f4bf // 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/image v0.0.0-20211028202545-6944b10bf410 // indirect
|
||||||
golang.org/x/mobile v0.0.0-20220112015953-858099ff7816 // indirect
|
golang.org/x/mobile v0.0.0-20220112015953-858099ff7816 // indirect
|
||||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
|
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
|
||||||
|
|||||||
6
go.sum
6
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/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/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.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/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-20181014144952-4e0d7dc8888f/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc=
|
||||||
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
|
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/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/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/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 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
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/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 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
|
||||||
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
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/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/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
|
||||||
github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
|
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 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 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-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-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-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"github.com/mmcdole/gofeed"
|
"github.com/mmcdole/gofeed"
|
||||||
|
|
||||||
"go.senan.xyz/gonic/server/db"
|
"go.senan.xyz/gonic/server/db"
|
||||||
"go.senan.xyz/gonic/server/encode"
|
|
||||||
"go.senan.xyz/gonic/server/scanner"
|
"go.senan.xyz/gonic/server/scanner"
|
||||||
"go.senan.xyz/gonic/server/scrobble/lastfm"
|
"go.senan.xyz/gonic/server/scrobble/lastfm"
|
||||||
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
|
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
|
||||||
|
"go.senan.xyz/gonic/server/transcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
func doScan(scanner *scanner.Scanner, opts scanner.ScanOptions) {
|
func doScan(scanner *scanner.Scanner, opts scanner.ScanOptions) {
|
||||||
@@ -67,7 +67,7 @@ func (c *Controller) ServeHome(r *http.Request) *Response {
|
|||||||
c.DB.
|
c.DB.
|
||||||
Where("user_id=?", user.ID).
|
Where("user_id=?", user.ID).
|
||||||
Find(&data.TranscodePreferences)
|
Find(&data.TranscodePreferences)
|
||||||
for profile := range encode.Profiles() {
|
for profile := range transcode.UserProfiles {
|
||||||
data.TranscodeProfiles = append(data.TranscodeProfiles, profile)
|
data.TranscodeProfiles = append(data.TranscodeProfiles, profile)
|
||||||
}
|
}
|
||||||
// podcasts box
|
// podcasts box
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"go.senan.xyz/gonic/server/jukebox"
|
"go.senan.xyz/gonic/server/jukebox"
|
||||||
"go.senan.xyz/gonic/server/podcasts"
|
"go.senan.xyz/gonic/server/podcasts"
|
||||||
"go.senan.xyz/gonic/server/scrobble"
|
"go.senan.xyz/gonic/server/scrobble"
|
||||||
|
"go.senan.xyz/gonic/server/transcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CtxKey int
|
type CtxKey int
|
||||||
@@ -34,6 +35,7 @@ type Controller struct {
|
|||||||
Jukebox *jukebox.Jukebox
|
Jukebox *jukebox.Jukebox
|
||||||
Scrobblers []scrobble.Scrobbler
|
Scrobblers []scrobble.Scrobbler
|
||||||
Podcasts *podcasts.Podcasts
|
Podcasts *podcasts.Podcasts
|
||||||
|
Transcoder transcode.Transcoder
|
||||||
}
|
}
|
||||||
|
|
||||||
type metaResponse struct {
|
type metaResponse struct {
|
||||||
|
|||||||
@@ -18,12 +18,22 @@ import (
|
|||||||
|
|
||||||
"go.senan.xyz/gonic/server/ctrlbase"
|
"go.senan.xyz/gonic/server/ctrlbase"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
|
||||||
|
"go.senan.xyz/gonic/server/db"
|
||||||
"go.senan.xyz/gonic/server/mockfs"
|
"go.senan.xyz/gonic/server/mockfs"
|
||||||
|
"go.senan.xyz/gonic/server/transcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])")
|
||||||
testDataDir = "testdata"
|
|
||||||
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 {
|
type queryCase struct {
|
||||||
@@ -37,22 +47,43 @@ func makeGoldenPath(test string) string {
|
|||||||
snake := testCamelExpr.ReplaceAllString(test, "${1}_${2}")
|
snake := testCamelExpr.ReplaceAllString(test, "${1}_${2}")
|
||||||
lower := strings.ToLower(snake)
|
lower := strings.ToLower(snake)
|
||||||
relPath := strings.ReplaceAll(lower, "/", "_")
|
relPath := strings.ReplaceAll(lower, "/", "_")
|
||||||
return path.Join(testDataDir, relPath)
|
return path.Join("testdata", relPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request) {
|
func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request) {
|
||||||
// ensure the handlers give us json
|
// ensure the handlers give us json
|
||||||
query.Add("f", "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
|
// request from the handler in question
|
||||||
req, _ := http.NewRequest("", "", nil)
|
req, _ := http.NewRequest("", "", nil)
|
||||||
req.URL.RawQuery = query.Encode()
|
req.URL.RawQuery = query.Encode()
|
||||||
subParams := params.New(req)
|
ctx := req.Context()
|
||||||
withParams := context.WithValue(req.Context(), CtxParams, subParams)
|
ctx = context.WithValue(ctx, CtxParams, params.New(req))
|
||||||
|
ctx = context.WithValue(ctx, CtxUser, &db.User{})
|
||||||
|
req = req.WithContext(ctx)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
req = req.WithContext(withParams)
|
|
||||||
return rr, req
|
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) {
|
func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []*queryCase) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, qc := range cases {
|
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 makeController(t *testing.T) *Controller { return makec(t, []string{""}, false) }
|
||||||
func makeControllerRoots(t *testing.T, r []string) *Controller { return makec(t, r) }
|
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()
|
t.Helper()
|
||||||
|
|
||||||
m := mockfs.NewWithDirs(t, roots)
|
m := mockfs.NewWithDirs(t, roots)
|
||||||
for _, root := range roots {
|
for _, root := range roots {
|
||||||
m.AddItemsPrefixWithCovers(root)
|
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.ScanAndClean()
|
||||||
m.ResetDates()
|
m.ResetDates()
|
||||||
m.LogAlbums()
|
|
||||||
|
|
||||||
var absRoots []string
|
var absRoots []string
|
||||||
for _, root := range roots {
|
for _, root := range roots {
|
||||||
@@ -117,7 +154,13 @@ func makec(t *testing.T, roots []string) *Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
base := &ctrlbase.Controller{DB: m.DB()}
|
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) {
|
func TestMain(m *testing.M) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package ctrlsubsonic
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,12 +12,13 @@ import (
|
|||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/jinzhu/gorm"
|
"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/params"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
|
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
|
||||||
"go.senan.xyz/gonic/server/db"
|
"go.senan.xyz/gonic/server/db"
|
||||||
"go.senan.xyz/gonic/server/encode"
|
"go.senan.xyz/gonic/server/transcode"
|
||||||
"go.senan.xyz/gonic/server/mime"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// "raw" handlers are ones that don't always return a spec response.
|
// "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).
|
First(&pref).
|
||||||
Error
|
Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return &pref, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("find transcode preference: %w", err)
|
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 {
|
if err != nil {
|
||||||
return spec.NewError(10, "please provide an `id` parameter")
|
return spec.NewError(10, "please provide an `id` parameter")
|
||||||
}
|
}
|
||||||
var audioFile db.AudioFile
|
var file db.AudioFile
|
||||||
var audioPath string
|
var audioPath string
|
||||||
switch id.Type {
|
switch id.Type {
|
||||||
case specid.Track:
|
case specid.Track:
|
||||||
@@ -250,103 +250,78 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return spec.NewError(70, "track with id `%s` was not found", id)
|
return spec.NewError(70, "track with id `%s` was not found", id)
|
||||||
}
|
}
|
||||||
audioFile = track
|
file = track
|
||||||
audioPath = path.Join(track.AbsPath())
|
audioPath = path.Join(track.AbsPath())
|
||||||
case specid.PodcastEpisode:
|
case specid.PodcastEpisode:
|
||||||
podcast, err := streamGetPodcast(c.DB, id.Value)
|
podcast, err := streamGetPodcast(c.DB, id.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spec.NewError(70, "podcast with id `%s` was not found", id)
|
return spec.NewError(70, "podcast with id `%s` was not found", id)
|
||||||
}
|
}
|
||||||
audioFile = podcast
|
file = podcast
|
||||||
audioPath = path.Join(c.PodcastsPath, podcast.Path)
|
audioPath = path.Join(c.PodcastsPath, podcast.Path)
|
||||||
default:
|
default:
|
||||||
return spec.NewError(70, "media type of `%s` was not found", id.Type)
|
return spec.NewError(70, "media type of `%s` was not found", id.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value(CtxUser).(*db.User)
|
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() {
|
defer func() {
|
||||||
if err := streamUpdateStats(c.DB, user.ID, track.Album.ID, time.Now()); err != nil {
|
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 format, _ := params.Get("format"); format == "raw" {
|
||||||
if err != nil {
|
|
||||||
return spec.NewError(0, "failed to get transcode stream preference: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
onInvalidProfile := func() error {
|
|
||||||
log.Printf("serving raw `%s`\n", audioFile.AudioFilename())
|
|
||||||
w.Header().Set("Content-Type", audioFile.MIME())
|
|
||||||
http.ServeFile(w, r, audioPath)
|
http.ServeFile(w, r, audioPath)
|
||||||
return nil
|
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)
|
pref, err := streamGetTransPref(c.DB, user.ID, params.GetOr("c", ""))
|
||||||
if err != nil {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return fmt.Errorf("failed to stat cache file `%s`: %w", path, err)
|
return spec.NewError(0, "couldn't find transcode preference: %v", err)
|
||||||
}
|
}
|
||||||
contentLength := fmt.Sprintf("%d", cacheFile.Size())
|
if pref == nil {
|
||||||
w.Header().Set("Content-Length", contentLength)
|
http.ServeFile(w, r, audioPath)
|
||||||
http.ServeFile(w, r, path)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec.Response {
|
profile, ok := transcode.UserProfiles[pref.Profile]
|
||||||
params := r.Context().Value(CtxParams).(params.Params)
|
if !ok {
|
||||||
id, err := params.GetID("id")
|
return spec.NewError(0, "unknown transcode user profile %q", pref.Profile)
|
||||||
|
}
|
||||||
|
if max, _ := params.GetInt("maxBitRate"); max > 0 && int(profile.BitRate()) > max {
|
||||||
|
profile = transcode.WithBitrate(profile, transcode.BitRate(max))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return spec.NewError(10, "please provide an `id` parameter")
|
return spec.NewError(0, "error transcoding: %v", err)
|
||||||
}
|
}
|
||||||
var filePath string
|
defer transcodeReader.Close()
|
||||||
var audioFile db.AudioFile
|
|
||||||
switch id.Type {
|
length := transcode.GuessExpectedSize(profile, time.Duration(file.AudioLength())*time.Second) // TODO: if there's no duration?
|
||||||
case specid.Track:
|
rreq, err := httprange.Parse(r.Header.Get("Range"), length)
|
||||||
track, _ := streamGetTrack(c.DB, id.Value)
|
if err != nil {
|
||||||
audioFile = track
|
return spec.NewError(0, "error parsing range: %v", err)
|
||||||
filePath = track.AbsPath()
|
}
|
||||||
if err != nil {
|
|
||||||
return spec.NewError(70, "track with id `%s` was not found", id)
|
w.Header().Set("Content-Type", profile.MIME())
|
||||||
}
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", rreq.Length))
|
||||||
case specid.PodcastEpisode:
|
w.Header().Set("Accept-Ranges", string(httprange.UnitBytes))
|
||||||
podcast, err := streamGetPodcast(c.DB, id.Value)
|
|
||||||
audioFile = podcast
|
if rreq.Partial {
|
||||||
filePath = path.Join(c.PodcastsPath, podcast.Path)
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
if err != nil {
|
w.Header().Set("Content-Range", fmt.Sprintf("%s %d-%d/%d", httprange.UnitBytes, rreq.Start, rreq.End, length))
|
||||||
return spec.NewError(70, "podcast with id `%s` was not found", id)
|
}
|
||||||
}
|
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
152
server/ctrlsubsonic/handlers_raw_test.go
Normal file
152
server/ctrlsubsonic/handlers_raw_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
59
server/ctrlsubsonic/httprange/httprange.go
Normal file
59
server/ctrlsubsonic/httprange/httprange.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package httprange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Unit string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UnitBytes Unit = "bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:gochecknoglobals
|
||||||
|
var (
|
||||||
|
reg = regexp.MustCompile(`^(?P<unit>\w+)=(?P<start>(?:\d+)?)\s*-\s*(?P<end>(?:\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
|
||||||
|
}
|
||||||
30
server/ctrlsubsonic/httprange/httprange_test.go
Normal file
30
server/ctrlsubsonic/httprange/httprange_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
BIN
server/ctrlsubsonic/testdata/audio/10s.flac
vendored
Normal file
BIN
server/ctrlsubsonic/testdata/audio/10s.flac
vendored
Normal file
Binary file not shown.
BIN
server/ctrlsubsonic/testdata/audio/5s.flac
vendored
Normal file
BIN
server/ctrlsubsonic/testdata/audio/5s.flac
vendored
Normal file
Binary file not shown.
@@ -72,10 +72,11 @@ type Genre struct {
|
|||||||
// AudioFile is used to avoid some duplication in handlers_raw.go
|
// AudioFile is used to avoid some duplication in handlers_raw.go
|
||||||
// between Track and Podcast
|
// between Track and Podcast
|
||||||
type AudioFile interface {
|
type AudioFile interface {
|
||||||
AudioFilename() string
|
|
||||||
Ext() string
|
Ext() string
|
||||||
MIME() string
|
MIME() string
|
||||||
|
AudioFilename() string
|
||||||
AudioBitrate() int
|
AudioBitrate() int
|
||||||
|
AudioLength() int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
@@ -100,6 +101,9 @@ type Track struct {
|
|||||||
TagBrainzID string `sql:"default: null"`
|
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 {
|
func (t *Track) SID() *specid.ID {
|
||||||
return &specid.ID{Type: specid.Track, Value: t.ID}
|
return &specid.ID{Type: specid.Track, Value: t.ID}
|
||||||
}
|
}
|
||||||
@@ -124,10 +128,6 @@ func (t *Track) AudioFilename() string {
|
|||||||
return t.Filename
|
return t.Filename
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Track) AudioBitrate() int {
|
|
||||||
return t.Bitrate
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Track) MIME() string {
|
func (t *Track) MIME() string {
|
||||||
v, _ := mime.FromExtension(t.Ext())
|
v, _ := mime.FromExtension(t.Ext())
|
||||||
return v
|
return v
|
||||||
@@ -364,6 +364,9 @@ type PodcastEpisode struct {
|
|||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pe *PodcastEpisode) AudioLength() int { return pe.Length }
|
||||||
|
func (pe *PodcastEpisode) AudioBitrate() int { return pe.Bitrate }
|
||||||
|
|
||||||
func (pe *PodcastEpisode) SID() *specid.ID {
|
func (pe *PodcastEpisode) SID() *specid.ID {
|
||||||
return &specid.ID{Type: specid.PodcastEpisode, Value: pe.ID}
|
return &specid.ID{Type: specid.PodcastEpisode, Value: pe.ID}
|
||||||
}
|
}
|
||||||
@@ -385,10 +388,6 @@ func (pe *PodcastEpisode) MIME() string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pe *PodcastEpisode) AudioBitrate() int {
|
|
||||||
return pe.Bitrate
|
|
||||||
}
|
|
||||||
|
|
||||||
type Bookmark struct {
|
type Bookmark struct {
|
||||||
ID int `gorm:"primary_key"`
|
ID int `gorm:"primary_key"`
|
||||||
User *User
|
User *User
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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() {
|
func (m *MockFS) LogItems() {
|
||||||
m.t.Logf("\nitems")
|
m.t.Logf("\nitems")
|
||||||
var items int
|
var items int
|
||||||
@@ -337,6 +353,9 @@ type Tags struct {
|
|||||||
RawAlbum string
|
RawAlbum string
|
||||||
RawAlbumArtist string
|
RawAlbumArtist string
|
||||||
RawGenre string
|
RawGenre string
|
||||||
|
|
||||||
|
RawBitrate int
|
||||||
|
RawLength int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Tags) Title() string { return m.RawTitle }
|
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) Genre() string { return m.RawGenre }
|
||||||
func (m *Tags) TrackNumber() int { return 1 }
|
func (m *Tags) TrackNumber() int { return 1 }
|
||||||
func (m *Tags) DiscNumber() 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) 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) SomeAlbum() string { return first("Unknown Album", m.Album()) }
|
||||||
func (m *Tags) SomeArtist() string { return first("Unknown Artist", m.Artist()) }
|
func (m *Tags) SomeArtist() string { return first("Unknown Artist", m.Artist()) }
|
||||||
func (m *Tags) SomeAlbumArtist() string { return first("Unknown Artist", m.AlbumArtist(), 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
|
return or
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstInt(or int, ints ...int) int {
|
||||||
|
for _, int := range ints {
|
||||||
|
if int > 0 {
|
||||||
|
return int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return or
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"go.senan.xyz/gonic/server/scrobble"
|
"go.senan.xyz/gonic/server/scrobble"
|
||||||
"go.senan.xyz/gonic/server/scrobble/lastfm"
|
"go.senan.xyz/gonic/server/scrobble/lastfm"
|
||||||
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
|
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
|
||||||
|
"go.senan.xyz/gonic/server/transcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -84,6 +85,11 @@ func New(opts Options) (*Server, error) {
|
|||||||
|
|
||||||
podcast := podcasts.New(opts.DB, opts.PodcastPath, tagger)
|
podcast := podcasts.New(opts.DB, opts.PodcastPath, tagger)
|
||||||
|
|
||||||
|
cacheTranscoder := transcode.NewCachingTranscoder(
|
||||||
|
transcode.NewFFmpegTranscoder(),
|
||||||
|
opts.CachePath,
|
||||||
|
)
|
||||||
|
|
||||||
ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast)
|
ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create admin controller: %w", err)
|
return nil, fmt.Errorf("create admin controller: %w", err)
|
||||||
@@ -97,6 +103,7 @@ func New(opts Options) (*Server, error) {
|
|||||||
Jukebox: &jukebox.Jukebox{},
|
Jukebox: &jukebox.Jukebox{},
|
||||||
Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}},
|
Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}},
|
||||||
Podcasts: podcast,
|
Podcasts: podcast,
|
||||||
|
Transcoder: cacheTranscoder,
|
||||||
}
|
}
|
||||||
|
|
||||||
setupMisc(r, base)
|
setupMisc(r, base)
|
||||||
@@ -222,9 +229,9 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) {
|
|||||||
r.Handle("/getSimilarSongs2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSimilarSongsTwo))
|
r.Handle("/getSimilarSongs2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSimilarSongsTwo))
|
||||||
|
|
||||||
// raw
|
// raw
|
||||||
r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeDownload))
|
|
||||||
r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt))
|
r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt))
|
||||||
r.Handle("/stream{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream))
|
r.Handle("/stream{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream))
|
||||||
|
r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream))
|
||||||
|
|
||||||
// browse by tag
|
// browse by tag
|
||||||
r.Handle("/getAlbum{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbum))
|
r.Handle("/getAlbum{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbum))
|
||||||
|
|||||||
BIN
server/transcode/testdata/10s.mp3
vendored
Normal file
BIN
server/transcode/testdata/10s.mp3
vendored
Normal file
Binary file not shown.
BIN
server/transcode/testdata/5s.mp3
vendored
Normal file
BIN
server/transcode/testdata/5s.mp3
vendored
Normal file
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
byte('Y')
|
||||||
|
byte('\x05')
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
byte('\x15')
|
||||||
|
byte('}')
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
byte('\a')
|
||||||
|
byte('\x02')
|
||||||
129
server/transcode/transcode.go
Normal file
129
server/transcode/transcode.go
Normal file
@@ -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 <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -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 <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -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 <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -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 <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -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 <file> -ss <seek> -map 0:a:0 -vn -b:a <bitrate> -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 <file> -ss <seek> -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 "<file>":
|
||||||
|
args = append(args, in)
|
||||||
|
case "<seek>":
|
||||||
|
args = append(args, fmt.Sprintf("%dus", profile.Seek().Microseconds()))
|
||||||
|
case "<bitrate>":
|
||||||
|
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
|
||||||
|
}
|
||||||
47
server/transcode/transcode_test.go
Normal file
47
server/transcode/transcode_test.go
Normal file
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
65
server/transcode/transcoder_caching.go
Normal file
65
server/transcode/transcoder_caching.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
39
server/transcode/transcoder_ffmpeg.go
Normal file
39
server/transcode/transcoder_ffmpeg.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
19
server/transcode/transcoder_none.go
Normal file
19
server/transcode/transcoder_none.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user