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

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.