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/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
|
||||
|
||||
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/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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
onInvalidProfile := func() error {
|
||||
log.Printf("serving raw `%s`\n", audioFile.AudioFilename())
|
||||
w.Header().Set("Content-Type", audioFile.MIME())
|
||||
if format, _ := params.Get("format"); format == "raw" {
|
||||
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)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
params := r.Context().Value(CtxParams).(params.Params)
|
||||
id, err := params.GetID("id")
|
||||
profile, ok := transcode.UserProfiles[pref.Profile]
|
||||
if !ok {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -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() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
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