Skip to content

Commit 3cfbbdf

Browse files
committed
feat(transcode): add a generic transcoding package for encoding/decoding/caching
1 parent a8e50c4 commit 3cfbbdf

19 files changed

+398
-368
lines changed

countrw/null.go

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package countrw
2+
3+
import "io"
4+
5+
type NullReader struct{}
6+
7+
func NewNullReader() *NullReader {
8+
return &NullReader{}
9+
}
10+
11+
func (*NullReader) Read(p []byte) (n int, err error) {
12+
return len(p), nil
13+
}
14+
15+
var _ io.Reader = (*NullReader)(nil)

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/disintegration/imaging v1.6.2
1212
github.com/dustin/go-humanize v1.0.0
1313
github.com/faiface/beep v1.1.0
14+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
1415
github.com/google/uuid v1.3.0 // indirect
1516
github.com/gorilla/mux v1.8.0
1617
github.com/gorilla/securecookie v1.1.1

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2V
5050
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
5151
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
5252
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
53+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
54+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
5355
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
5456
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
5557
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=

server/ctrladmin/handlers.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import (
1010
"github.com/mmcdole/gofeed"
1111

1212
"go.senan.xyz/gonic/server/db"
13-
"go.senan.xyz/gonic/server/encode"
1413
"go.senan.xyz/gonic/server/scanner"
1514
"go.senan.xyz/gonic/server/scrobble/lastfm"
1615
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
16+
"go.senan.xyz/gonic/server/transcode"
1717
)
1818

1919
func doScan(scanner *scanner.Scanner, opts scanner.ScanOptions) {
@@ -67,7 +67,7 @@ func (c *Controller) ServeHome(r *http.Request) *Response {
6767
c.DB.
6868
Where("user_id=?", user.ID).
6969
Find(&data.TranscodePreferences)
70-
for profile := range encode.Profiles() {
70+
for profile := range transcode.UserProfiles {
7171
data.TranscodeProfiles = append(data.TranscodeProfiles, profile)
7272
}
7373
// podcasts box

server/ctrlsubsonic/ctrl.go

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"go.senan.xyz/gonic/server/jukebox"
1616
"go.senan.xyz/gonic/server/podcasts"
1717
"go.senan.xyz/gonic/server/scrobble"
18+
"go.senan.xyz/gonic/server/transcode"
1819
)
1920

2021
type CtxKey int
@@ -34,6 +35,7 @@ type Controller struct {
3435
Jukebox *jukebox.Jukebox
3536
Scrobblers []scrobble.Scrobbler
3637
Podcasts *podcasts.Podcasts
38+
Transcoder transcode.Transcoder
3739
}
3840

3941
type metaResponse struct {

server/ctrlsubsonic/handlers_raw.go

+37-72
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import (
1313
"github.com/disintegration/imaging"
1414
"github.com/jinzhu/gorm"
1515

16+
"go.senan.xyz/gonic/countrw"
1617
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
1718
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
1819
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
1920
"go.senan.xyz/gonic/server/db"
20-
"go.senan.xyz/gonic/server/encode"
21-
"go.senan.xyz/gonic/server/mime"
21+
"go.senan.xyz/gonic/server/transcode"
2222
)
2323

2424
// "raw" handlers are ones that don't always return a spec response.
@@ -242,111 +242,76 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
242242
if err != nil {
243243
return spec.NewError(10, "please provide an `id` parameter")
244244
}
245-
var audioFile db.AudioFile
245+
var file db.AudioFile
246246
var audioPath string
247247
switch id.Type {
248248
case specid.Track:
249249
track, err := streamGetTrack(c.DB, id.Value)
250250
if err != nil {
251251
return spec.NewError(70, "track with id `%s` was not found", id)
252252
}
253-
audioFile = track
253+
file = track
254254
audioPath = path.Join(track.AbsPath())
255255
case specid.PodcastEpisode:
256256
podcast, err := streamGetPodcast(c.DB, id.Value)
257257
if err != nil {
258258
return spec.NewError(70, "podcast with id `%s` was not found", id)
259259
}
260-
audioFile = podcast
260+
file = podcast
261261
audioPath = path.Join(c.PodcastsPath, podcast.Path)
262262
default:
263263
return spec.NewError(70, "media type of `%s` was not found", id.Type)
264264
}
265265

266266
user := r.Context().Value(CtxUser).(*db.User)
267-
if track, ok := audioFile.(*db.Track); ok && track.Album != nil {
267+
if track, ok := file.(*db.Track); ok && track.Album != nil {
268268
defer func() {
269269
if err := streamUpdateStats(c.DB, user.ID, track.Album.ID, time.Now()); err != nil {
270-
log.Printf("error updating listen stats: %v", err)
270+
log.Printf("error updating status: %v", err)
271271
}
272272
}()
273273
}
274274

275-
pref, err := streamGetTransPref(c.DB, user.ID, params.GetOr("c", ""))
276-
if err != nil {
277-
return spec.NewError(0, "failed to get transcode stream preference: %v", err)
275+
if format, _ := params.Get("format"); format == "raw" {
276+
http.ServeFile(w, r, audioPath)
277+
return nil
278278
}
279279

280-
onInvalidProfile := func() error {
281-
log.Printf("serving raw `%s`\n", audioFile.AudioFilename())
282-
w.Header().Set("Content-Type", audioFile.MIME())
280+
pref, err := streamGetTransPref(c.DB, user.ID, params.GetOr("c", ""))
281+
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
282+
return spec.NewError(0, "couldn't find transcode preference: %v", err)
283+
}
284+
if pref == nil {
283285
http.ServeFile(w, r, audioPath)
284286
return nil
285287
}
286-
onCacheHit := func(profile encode.Profile, path string) error {
287-
log.Printf("serving transcode `%s`: cache [%s/%dk] hit!\n",
288-
audioFile.AudioFilename(), profile.Format, profile.Bitrate)
289-
cacheMime, _ := mime.FromExtension(profile.Format)
290-
w.Header().Set("Content-Type", cacheMime)
291288

292-
cacheFile, err := os.Stat(path)
293-
if err != nil {
294-
return fmt.Errorf("failed to stat cache file `%s`: %w", path, err)
295-
}
296-
contentLength := fmt.Sprintf("%d", cacheFile.Size())
297-
w.Header().Set("Content-Length", contentLength)
298-
http.ServeFile(w, r, path)
299-
return nil
289+
profile, ok := transcode.UserProfiles[pref.Profile]
290+
if !ok {
291+
return spec.NewError(0, "unknown transcode user profile %q", pref.Profile)
300292
}
301-
onCacheMiss := func(profile encode.Profile) (io.Writer, error) {
302-
log.Printf("serving transcode `%s`: cache [%s/%dk] miss!\n",
303-
audioFile.AudioFilename(), profile.Format, profile.Bitrate)
304-
encodeMime, _ := mime.FromExtension(profile.Format)
305-
w.Header().Set("Content-Type", encodeMime)
306-
return w, nil
307-
}
308-
encodeOptions := encode.Options{
309-
TrackPath: audioPath,
310-
TrackBitrate: audioFile.AudioBitrate(),
311-
CachePath: c.CachePath,
312-
ProfileName: pref.Profile,
313-
PreferredBitrate: params.GetOrInt("maxBitRate", 0),
314-
OnInvalidProfile: onInvalidProfile,
315-
OnCacheHit: onCacheHit,
316-
OnCacheMiss: onCacheMiss,
317-
}
318-
if err := encode.Encode(encodeOptions); err != nil {
319-
log.Printf("serving transcode `%s`: error: %v\n", audioFile.AudioFilename(), err)
293+
if max, _ := params.GetInt("maxBitRate"); max > 0 && int(profile.BitRate()) > max {
294+
profile = transcode.WithBitrate(profile, transcode.BitRate(max))
320295
}
321-
return nil
322-
}
323296

324-
func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec.Response {
325-
params := r.Context().Value(CtxParams).(params.Params)
326-
id, err := params.GetID("id")
327-
if err != nil {
328-
return spec.NewError(10, "please provide an `id` parameter")
297+
log.Printf("trancoding to %q with max bitrate %dk", profile.MIME(), profile.BitRate())
298+
299+
guessedSize := transcode.GuessExpectedSize(profile, time.Duration(file.AudioLength())*time.Second)
300+
w.Header().Set("Content-Type", profile.MIME())
301+
w.Header().Set("Content-Length", fmt.Sprintf("%d", guessedSize))
302+
303+
cw := countrw.NewCountWriter(w)
304+
if err := c.Transcoder.Transcode(r.Context(), profile, audioPath, cw); err != nil {
305+
return spec.NewError(0, "error transcoding %v", err)
329306
}
330-
var filePath string
331-
var audioFile db.AudioFile
332-
switch id.Type {
333-
case specid.Track:
334-
track, _ := streamGetTrack(c.DB, id.Value)
335-
audioFile = track
336-
filePath = track.AbsPath()
337-
if err != nil {
338-
return spec.NewError(70, "track with id `%s` was not found", id)
339-
}
340-
case specid.PodcastEpisode:
341-
podcast, err := streamGetPodcast(c.DB, id.Value)
342-
audioFile = podcast
343-
filePath = path.Join(c.PodcastsPath, podcast.Path)
344-
if err != nil {
345-
return spec.NewError(70, "podcast with id `%s` was not found", id)
346-
}
307+
308+
// pad out the response with 0 up until the Content-Length we promised
309+
remains := int64(guessedSize) - int64(cw.Count())
310+
padding := io.LimitReader(&countrw.NullReader{}, remains)
311+
_, _ = io.Copy(cw, padding)
312+
313+
if f, ok := w.(http.Flusher); ok {
314+
f.Flush()
347315
}
348-
log.Printf("serving raw `%s`\n", audioFile.AudioFilename())
349-
w.Header().Set("Content-Type", audioFile.MIME())
350-
http.ServeFile(w, r, filePath)
351316
return nil
352317
}

server/db/model.go

+8-9
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,11 @@ type Genre struct {
7272
// AudioFile is used to avoid some duplication in handlers_raw.go
7373
// between Track and Podcast
7474
type AudioFile interface {
75-
AudioFilename() string
7675
Ext() string
7776
MIME() string
77+
AudioFilename() string
7878
AudioBitrate() int
79+
AudioLength() int
7980
}
8081

8182
type Track struct {
@@ -100,6 +101,9 @@ type Track struct {
100101
TagBrainzID string `sql:"default: null"`
101102
}
102103

104+
func (t *Track) AudioLength() int { return t.Length }
105+
func (t *Track) AudioBitrate() int { return t.Bitrate }
106+
103107
func (t *Track) SID() *specid.ID {
104108
return &specid.ID{Type: specid.Track, Value: t.ID}
105109
}
@@ -124,10 +128,6 @@ func (t *Track) AudioFilename() string {
124128
return t.Filename
125129
}
126130

127-
func (t *Track) AudioBitrate() int {
128-
return t.Bitrate
129-
}
130-
131131
func (t *Track) MIME() string {
132132
v, _ := mime.FromExtension(t.Ext())
133133
return v
@@ -364,6 +364,9 @@ type PodcastEpisode struct {
364364
Error string
365365
}
366366

367+
func (pe *PodcastEpisode) AudioLength() int { return pe.Length }
368+
func (pe *PodcastEpisode) AudioBitrate() int { return pe.Bitrate }
369+
367370
func (pe *PodcastEpisode) SID() *specid.ID {
368371
return &specid.ID{Type: specid.PodcastEpisode, Value: pe.ID}
369372
}
@@ -385,10 +388,6 @@ func (pe *PodcastEpisode) MIME() string {
385388
return v
386389
}
387390

388-
func (pe *PodcastEpisode) AudioBitrate() int {
389-
return pe.Bitrate
390-
}
391-
392391
type Bookmark struct {
393392
ID int `gorm:"primary_key"`
394393
User *User

0 commit comments

Comments
 (0)