feat(transcode): add a generic transcoding package for encoding/decoding/caching

This commit is contained in:
sentriz
2022-03-10 00:42:52 +00:00
parent fd211d706a
commit 165904c2bb
25 changed files with 713 additions and 388 deletions

5
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
}

View 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
}

View 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
}

View 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))
}

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

Binary file not shown.

BIN
server/transcode/testdata/5s.mp3 vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,3 @@
go test fuzz v1
byte('Y')
byte('\x05')

View File

@@ -0,0 +1,3 @@
go test fuzz v1
byte('\x15')
byte('}')

View File

@@ -0,0 +1,3 @@
go test fuzz v1
byte('\a')
byte('\x02')

View 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
}

View 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))
})
}

View 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))
}

View 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
}

View 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)
}