Skip to content

Commit 1021d6a

Browse files
committed
feat(transcode): add a generic transcoding package for encoding/decoding/caching
1 parent a55cd81 commit 1021d6a

18 files changed

+418
-385
lines changed

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

+75-89
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,20 @@ package ctrlsubsonic
33
import (
44
"errors"
55
"fmt"
6-
"io"
76
"log"
87
"net/http"
98
"os"
109
"path"
1110
"time"
1211

1312
"github.com/disintegration/imaging"
13+
"github.com/jinzhu/gorm"
1414

1515
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
1616
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
1717
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
1818
"go.senan.xyz/gonic/server/db"
19-
"go.senan.xyz/gonic/server/encode"
20-
"go.senan.xyz/gonic/server/mime"
19+
"go.senan.xyz/gonic/server/transcode"
2120
)
2221

2322
// "raw" handlers are ones that don't always return a spec response.
@@ -26,42 +25,60 @@ import (
2625
// b) return a non-nil spec.Response
2726
// _but not both_
2827

29-
func streamGetTransPref(dbc *db.DB, userID int, client string) db.TranscodePreference {
30-
pref := db.TranscodePreference{}
31-
dbc.
28+
func streamGetTransPref(dbc *db.DB, userID int, client string) (*db.TranscodePreference, error) {
29+
var pref db.TranscodePreference
30+
err := dbc.
3231
Where("user_id=?", userID).
3332
Where("client COLLATE NOCASE IN (?)", []string{"*", client}).
3433
Order("client DESC"). // ensure "*" is last if it's there
35-
First(&pref)
36-
return pref
34+
First(&pref).
35+
Error
36+
if err != nil {
37+
return nil, fmt.Errorf("search sort: %w", err)
38+
}
39+
return &pref, nil
3740
}
3841

3942
func streamGetTrack(dbc *db.DB, trackID int) (*db.Track, error) {
40-
track := db.Track{}
43+
var track db.Track
4144
err := dbc.
4245
Preload("Album").
4346
First(&track, trackID).
4447
Error
45-
return &track, err
48+
if err != nil {
49+
return nil, fmt.Errorf("search track: %w", err)
50+
}
51+
return &track, nil
4652
}
4753

4854
func streamGetPodcast(dbc *db.DB, podcastID int) (*db.PodcastEpisode, error) {
49-
podcast := db.PodcastEpisode{}
55+
var podcast db.PodcastEpisode
5056
err := dbc.First(&podcast, podcastID).Error
51-
return &podcast, err
57+
if err != nil {
58+
return nil, fmt.Errorf("search podcast: %w", err)
59+
}
60+
return &podcast, nil
5261
}
5362

54-
func streamUpdateStats(dbc *db.DB, userID, albumID int) {
55-
play := db.Play{
56-
AlbumID: albumID,
57-
UserID: userID,
58-
}
59-
dbc.
63+
func streamUpdateStats(dbc *db.DB, userID, albumID int) error {
64+
var play db.Play
65+
play.AlbumID = albumID
66+
play.UserID = userID
67+
68+
err := dbc.
6069
Where(play).
61-
First(&play)
70+
FirstOrCreate(&play).
71+
Error
72+
if err != nil {
73+
return fmt.Errorf("first or create stats: %w", err)
74+
}
6275
play.Time = time.Now() // for getAlbumList?type=recent
6376
play.Count++ // for getAlbumList?type=frequent
64-
dbc.Save(&play)
77+
78+
if err := dbc.Save(&play).Error; err != nil {
79+
return fmt.Errorf("save stats: %w", err)
80+
}
81+
return nil
6582
}
6683

6784
const (
@@ -219,103 +236,72 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
219236
if err != nil {
220237
return spec.NewError(10, "please provide an `id` parameter")
221238
}
222-
var audioFile db.AudioFile
239+
var file db.AudioFile
223240
var audioPath string
224241
switch id.Type {
225242
case specid.Track:
226243
track, err := streamGetTrack(c.DB, id.Value)
227244
if err != nil {
228245
return spec.NewError(70, "track with id `%s` was not found", id)
229246
}
230-
audioFile = track
247+
file = track
231248
audioPath = path.Join(track.AbsPath())
232249
case specid.PodcastEpisode:
233250
podcast, err := streamGetPodcast(c.DB, id.Value)
234251
if err != nil {
235252
return spec.NewError(70, "podcast with id `%s` was not found", id)
236253
}
237-
audioFile = podcast
254+
file = podcast
238255
audioPath = path.Join(c.PodcastsPath, podcast.Path)
239256
default:
240257
return spec.NewError(70, "media type of `%s` was not found", id.Type)
241258
}
242259

243260
user := r.Context().Value(CtxUser).(*db.User)
244-
if track, ok := audioFile.(*db.Track); ok && track.Album != nil {
245-
defer streamUpdateStats(c.DB, user.ID, track.Album.ID)
261+
if track, ok := file.(*db.Track); ok && track.Album != nil {
262+
defer func() {
263+
if err := streamUpdateStats(c.DB, user.ID, track.Album.ID); err != nil {
264+
log.Printf("error updating status: %v", err)
265+
}
266+
}()
246267
}
247268

248-
pref := streamGetTransPref(c.DB, user.ID, params.GetOr("c", ""))
249-
onInvalidProfile := func() error {
250-
log.Printf("serving raw `%s`\n", audioFile.AudioFilename())
251-
w.Header().Set("Content-Type", audioFile.MIME())
252-
http.ServeFile(w, r, audioPath)
253-
return nil
269+
client := params.GetOr("c", "")
270+
maxBitRate := params.GetOrInt("maxBitRate", 0)
271+
272+
logt := func(f string, a ...interface{}) {
273+
log.Printf("requested %q (%dkbps, max %dkbps) %s",
274+
file.AudioFilename(), file.AudioBitrate(), maxBitRate, fmt.Sprintf(f, a...))
254275
}
255-
onCacheHit := func(profile encode.Profile, path string) error {
256-
log.Printf("serving transcode `%s`: cache [%s/%dk] hit!\n",
257-
audioFile.AudioFilename(), profile.Format, profile.Bitrate)
258-
cacheMime, _ := mime.FromExtension(profile.Format)
259-
w.Header().Set("Content-Type", cacheMime)
260276

261-
cacheFile, err := os.Stat(path)
262-
if err != nil {
263-
return fmt.Errorf("failed to stat cache file `%s`: %w", path, err)
264-
}
265-
contentLength := fmt.Sprintf("%d", cacheFile.Size())
266-
w.Header().Set("Content-Length", contentLength)
267-
http.ServeFile(w, r, path)
268-
return nil
277+
pref, err := streamGetTransPref(c.DB, user.ID, client)
278+
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
279+
return spec.NewError(0, "couldn't find transcode preference: %v", err)
269280
}
270-
onCacheMiss := func(profile encode.Profile) (io.Writer, error) {
271-
log.Printf("serving transcode `%s`: cache [%s/%dk] miss!\n",
272-
audioFile.AudioFilename(), profile.Format, profile.Bitrate)
273-
encodeMime, _ := mime.FromExtension(profile.Format)
274-
w.Header().Set("Content-Type", encodeMime)
275-
return w, nil
281+
if pref == nil {
282+
logt("not transcoding")
283+
http.ServeFile(w, r, audioPath)
284+
return nil
276285
}
277-
encodeOptions := encode.Options{
278-
TrackPath: audioPath,
279-
TrackBitrate: audioFile.AudioBitrate(),
280-
CachePath: c.CachePath,
281-
ProfileName: pref.Profile,
282-
PreferredBitrate: params.GetOrInt("maxBitRate", 0),
283-
OnInvalidProfile: onInvalidProfile,
284-
OnCacheHit: onCacheHit,
285-
OnCacheMiss: onCacheMiss,
286+
287+
profile, ok := transcode.UserProfiles[pref.Profile]
288+
if !ok {
289+
return spec.NewError(0, "unknown transcode user profile %q", pref.Profile)
286290
}
287-
if err := encode.Encode(encodeOptions); err != nil {
288-
log.Printf("serving transcode `%s`: error: %v\n", audioFile.AudioFilename(), err)
291+
if maxBitRate > 0 && int(profile.BitRate()) > maxBitRate {
292+
profile = transcode.WithBitrate(profile, transcode.BitRate(maxBitRate))
293+
logt("capping bitrate and transcoding / reading cache")
294+
} else {
295+
logt("transcoding / reading cache")
289296
}
290-
return nil
291-
}
292297

293-
func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec.Response {
294-
params := r.Context().Value(CtxParams).(params.Params)
295-
id, err := params.GetID("id")
296-
if err != nil {
297-
return spec.NewError(10, "please provide an `id` parameter")
298-
}
299-
var filePath string
300-
var audioFile db.AudioFile
301-
switch id.Type {
302-
case specid.Track:
303-
track, _ := streamGetTrack(c.DB, id.Value)
304-
audioFile = track
305-
filePath = track.AbsPath()
306-
if err != nil {
307-
return spec.NewError(70, "track with id `%s` was not found", id)
308-
}
309-
case specid.PodcastEpisode:
310-
podcast, err := streamGetPodcast(c.DB, id.Value)
311-
audioFile = podcast
312-
filePath = path.Join(c.PodcastsPath, podcast.Path)
313-
if err != nil {
314-
return spec.NewError(70, "podcast with id `%s` was not found", id)
315-
}
298+
guessedSizeBytes := transcode.GuessExpectedSize(profile, time.Duration(file.AudioLength())*time.Second)
299+
w.Header().Set("Content-Type", profile.MIME())
300+
w.Header().Set("Content-Length", fmt.Sprint(guessedSizeBytes))
301+
w.Header().Set("Accept-Ranges", "none")
302+
303+
if err := c.Transcoder.Transcode(r.Context(), profile, audioPath, w); err != nil {
304+
return spec.NewError(0, "error transcoding %v", err)
316305
}
317-
log.Printf("serving raw `%s`\n", audioFile.AudioFilename())
318-
w.Header().Set("Content-Type", audioFile.MIME())
319-
http.ServeFile(w, r, filePath)
320306
return nil
321307
}

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)