feat(transcode): add a generic transcoding package for encoding/decoding/caching
This commit is contained in:
@@ -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.
Reference in New Issue
Block a user