diff --git a/db/db_test.go b/db/db_test.go index cacc2fd..0fb2bf1 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -11,13 +11,9 @@ import ( "github.com/stretchr/testify/require" ) -func randKey() string { - letters := []rune("abcdef0123456789") - b := make([]rune, 16) - for i := range b { - b[i] = letters[rand.Intn(len(letters))] - } - return string(b) +func TestMain(m *testing.M) { + log.SetOutput(io.Discard) + os.Exit(m.Run()) } func TestGetSetting(t *testing.T) { @@ -46,7 +42,11 @@ func TestGetSetting(t *testing.T) { require.Equal(t, value, actual) } -func TestMain(m *testing.M) { - log.SetOutput(io.Discard) - os.Exit(m.Run()) +func randKey() string { + letters := []rune("abcdef0123456789") + b := make([]rune, 16) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) } diff --git a/db/model.go b/db/model.go index 43e7481..fceb3ed 100644 --- a/db/model.go +++ b/db/model.go @@ -1,9 +1,6 @@ //nolint:lll // struct tags get very long and can't be split package db -// see this db fiddle to mess around with the schema -// https://www.db-fiddle.com/f/wJ7z8L7mu6ZKaYmWk1xr1p/5 - import ( "fmt" "path/filepath" @@ -17,30 +14,6 @@ import ( "go.senan.xyz/gonic/server/ctrlsubsonic/specid" ) -func splitIDs(in, sep string) []specid.ID { - if in == "" { - return []specid.ID{} - } - parts := strings.Split(in, sep) - ret := make([]specid.ID, 0, len(parts)) - for _, p := range parts { - id, _ := specid.New(p) - ret = append(ret, id) - } - return ret -} - -func join[T fmt.Stringer](in []T, sep string) string { - if in == nil { - return "" - } - strs := make([]string, 0, len(in)) - for _, id := range in { - strs = append(strs, id.String()) - } - return strings.Join(strs, sep) -} - type Artist struct { ID int `gorm:"primary_key"` Name string `gorm:"not null; unique_index"` @@ -461,3 +434,27 @@ func (p *ArtistInfo) SetSimilarArtists(items []string) { p.SimilarArtists = stri func (p *ArtistInfo) GetTopTracks() []string { return strings.Split(p.TopTracks, ";") } func (p *ArtistInfo) SetTopTracks(items []string) { p.TopTracks = strings.Join(items, ";") } + +func splitIDs(in, sep string) []specid.ID { + if in == "" { + return []specid.ID{} + } + parts := strings.Split(in, sep) + ret := make([]specid.ID, 0, len(parts)) + for _, p := range parts { + id, _ := specid.New(p) + ret = append(ret, id) + } + return ret +} + +func join[T fmt.Stringer](in []T, sep string) string { + if in == nil { + return "" + } + strs := make([]string, 0, len(in)) + for _, id := range in { + strs = append(strs, id.String()) + } + return strings.Join(strs, sep) +} diff --git a/jukebox/jukebox_test.go b/jukebox/jukebox_test.go index 77c26a6..15dfeb7 100644 --- a/jukebox/jukebox_test.go +++ b/jukebox/jukebox_test.go @@ -11,28 +11,6 @@ import ( "go.senan.xyz/gonic/jukebox" ) -func newJukebox(tb testing.TB) *jukebox.Jukebox { - tb.Helper() - - sockPath := filepath.Join(tb.TempDir(), "mpv.sock") - - j := jukebox.New() - err := j.Start( - sockPath, - []string{jukebox.MPVArg("--ao", "null")}, - ) - if errors.Is(err, jukebox.ErrMPVTooOld) { - tb.Skip("old mpv found, skipping") - } - if err != nil { - tb.Fatalf("start jukebox: %v", err) - } - tb.Cleanup(func() { - j.Quit() - }) - return j -} - func TestPlaySkipReset(t *testing.T) { t.Skip("bit flakey currently") @@ -187,6 +165,28 @@ func TestVolume(t *testing.T) { require.Equal(t, 0.0, vol) } +func newJukebox(tb testing.TB) *jukebox.Jukebox { + tb.Helper() + + sockPath := filepath.Join(tb.TempDir(), "mpv.sock") + + j := jukebox.New() + err := j.Start( + sockPath, + []string{jukebox.MPVArg("--ao", "null")}, + ) + if errors.Is(err, jukebox.ErrMPVTooOld) { + tb.Skip("old mpv found, skipping") + } + if err != nil { + tb.Fatalf("start jukebox: %v", err) + } + tb.Cleanup(func() { + j.Quit() + }) + return j +} + func testPath(path string) string { cwd, _ := os.Getwd() return filepath.Join(cwd, "testdata", path) diff --git a/mime/mime.go b/mime/mime.go index d22f592..8e750de 100644 --- a/mime/mime.go +++ b/mime/mime.go @@ -7,19 +7,6 @@ import ( "strings" ) -var supportedAudioTypes = map[string]string{ - ".mp3": "audio/mpeg", - ".flac": "audio/x-flac", - ".aac": "audio/x-aac", - ".m4a": "audio/m4a", - ".m4b": "audio/m4b", - ".ogg": "audio/ogg", - ".opus": "audio/ogg", - ".wma": "audio/x-ms-wma", - ".wav": "audio/x-wav", - ".wv": "audio/x-wavpack", -} - //nolint:gochecknoinits func init() { for ext, mime := range supportedAudioTypes { @@ -41,3 +28,16 @@ func TypeByAudioExtension(ext string) string { } return stdmime.TypeByExtension(ext) } + +var supportedAudioTypes = map[string]string{ + ".mp3": "audio/mpeg", + ".flac": "audio/x-flac", + ".aac": "audio/x-aac", + ".m4a": "audio/m4a", + ".m4b": "audio/m4b", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + ".wma": "audio/x-ms-wma", + ".wav": "audio/x-wav", + ".wv": "audio/x-wavpack", +} diff --git a/server/ctrlsubsonic/ctrl_test.go b/server/ctrlsubsonic/ctrl_test.go index 1d1c28c..2ed34ca 100644 --- a/server/ctrlsubsonic/ctrl_test.go +++ b/server/ctrlsubsonic/ctrl_test.go @@ -23,6 +23,12 @@ import ( "go.senan.xyz/gonic/transcode" ) +func TestMain(m *testing.M) { + gonic.Version = "" + log.SetOutput(io.Discard) + os.Exit(m.Run()) +} + var testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])") const ( @@ -156,9 +162,3 @@ func makec(tb testing.TB, roots []string, audio bool) *Controller { return contr } - -func TestMain(m *testing.M) { - gonic.Version = "" - log.SetOutput(io.Discard) - os.Exit(m.Run()) -} diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 7146404..d41c66d 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -30,43 +30,46 @@ import ( // b) return a non-nil spec.Response // _but not both_ -func streamGetTransodePreference(dbc *db.DB, userID int, client string) (*db.TranscodePreference, error) { - var pref db.TranscodePreference - err := dbc. - Where("user_id=?", userID). - Where("client COLLATE NOCASE IN (?)", []string{"*", client}). - Order("client DESC"). // ensure "*" is last if it's there - First(&pref). - Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("find transcode preference: %w", err) - } - return &pref, nil -} - -func streamGetTranscodeMeta(dbc *db.DB, userID int, client string) spec.TranscodeMeta { - pref, _ := streamGetTransodePreference(dbc, userID, client) - if pref == nil { - return spec.TranscodeMeta{} - } - profile, ok := transcode.UserProfiles[pref.Profile] - if !ok { - return spec.TranscodeMeta{} - } - return spec.TranscodeMeta{ - TranscodedContentType: profile.MIME(), - TranscodedSuffix: profile.Suffix(), - } -} - const ( coverDefaultSize = 600 coverCacheFormat = "png" ) +func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *spec.Response { + params := r.Context().Value(CtxParams).(params.Params) + id, err := params.GetID("id") + if err != nil { + return spec.NewError(10, "please provide an `id` parameter") + } + size := params.GetOrInt("size", coverDefaultSize) + cachePath := filepath.Join( + c.cacheCoverPath, + fmt.Sprintf("%s-%d.%s", id.String(), size, coverCacheFormat), + ) + _, err = os.Stat(cachePath) + switch { + case os.IsNotExist(err): + reader, err := coverFor(c.dbc, c.artistInfoCache, id) + if err != nil { + return spec.NewError(10, "couldn't find cover `%s`: %v", id, err) + } + defer reader.Close() + + if err := coverScaleAndSave(reader, cachePath, size); err != nil { + log.Printf("error scaling cover: %v", err) + return nil + } + case err != nil: + log.Printf("error stating `%s`: %v", cachePath, err) + return nil + } + + w.Header().Set("Cache-Control", "public, max-age=3600") + http.ServeFile(w, r, cachePath) + + return nil +} + var ( errCoverNotFound = errors.New("could not find a cover with that id") errCoverEmpty = errors.New("no cover found") @@ -163,41 +166,6 @@ func coverScaleAndSave(reader io.Reader, cachePath string, size int) error { return nil } -func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *spec.Response { - params := r.Context().Value(CtxParams).(params.Params) - id, err := params.GetID("id") - if err != nil { - return spec.NewError(10, "please provide an `id` parameter") - } - size := params.GetOrInt("size", coverDefaultSize) - cachePath := filepath.Join( - c.cacheCoverPath, - fmt.Sprintf("%s-%d.%s", id.String(), size, coverCacheFormat), - ) - _, err = os.Stat(cachePath) - switch { - case os.IsNotExist(err): - reader, err := coverFor(c.dbc, c.artistInfoCache, id) - if err != nil { - return spec.NewError(10, "couldn't find cover `%s`: %v", id, err) - } - defer reader.Close() - - if err := coverScaleAndSave(reader, cachePath, size); err != nil { - log.Printf("error scaling cover: %v", err) - return nil - } - case err != nil: - log.Printf("error stating `%s`: %v", cachePath, err) - return nil - } - - w.Header().Set("Cache-Control", "public, max-age=3600") - http.ServeFile(w, r, cachePath) - - return nil -} - func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.Response { params := r.Context().Value(CtxParams).(params.Params) user := r.Context().Value(CtxUser).(*db.User) @@ -268,3 +236,35 @@ func (c *Controller) ServeGetAvatar(w http.ResponseWriter, r *http.Request) *spe http.ServeContent(w, r, "", time.Now(), bytes.NewReader(reqUser.Avatar)) return nil } + +func streamGetTransodePreference(dbc *db.DB, userID int, client string) (*db.TranscodePreference, error) { + var pref db.TranscodePreference + err := dbc. + Where("user_id=?", userID). + Where("client COLLATE NOCASE IN (?)", []string{"*", client}). + Order("client DESC"). // ensure "*" is last if it's there + First(&pref). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find transcode preference: %w", err) + } + return &pref, nil +} + +func streamGetTranscodeMeta(dbc *db.DB, userID int, client string) spec.TranscodeMeta { + pref, _ := streamGetTransodePreference(dbc, userID, client) + if pref == nil { + return spec.TranscodeMeta{} + } + profile, ok := transcode.UserProfiles[pref.Profile] + if !ok { + return spec.TranscodeMeta{} + } + return spec.TranscodeMeta{ + TranscodedContentType: profile.MIME(), + TranscodedSuffix: profile.Suffix(), + } +} diff --git a/server/ctrlsubsonic/params/params.go b/server/ctrlsubsonic/params/params.go index bb4658f..022c4e2 100644 --- a/server/ctrlsubsonic/params/params.go +++ b/server/ctrlsubsonic/params/params.go @@ -37,96 +37,6 @@ import ( var ErrNoValues = errors.New("no values provided") -// some thin wrappers -// may be needed when cleaning up parse() below -func parseStr(in string) (string, error) { return in, nil } -func parseInt(in string) (int, error) { return strconv.Atoi(in) } -func parseFloat(in string) (float64, error) { return strconv.ParseFloat(in, 64) } -func parseID(in string) (specid.ID, error) { return specid.New(in) } -func parseBool(in string) (bool, error) { return strconv.ParseBool(in) } - -func parseTime(in string) (time.Time, error) { - ms, err := strconv.Atoi(in) - if err != nil { - return time.Time{}, err - } - ns := int64(ms) * 1e6 - return time.Unix(0, ns), nil -} - -func parse(values []string, i interface{}) error { - if len(values) == 0 { - return ErrNoValues - } - var err error - switch v := i.(type) { - // *T - case *string: - *v, err = parseStr(values[0]) - case *int: - *v, err = parseInt(values[0]) - case *float64: - *v, err = parseFloat(values[0]) - case *specid.ID: - *v, err = parseID(values[0]) - case *bool: - *v, err = parseBool(values[0]) - case *time.Time: - *v, err = parseTime(values[0]) - - // *[]T - case *[]string: - for _, value := range values { - parsed, err := parseStr(value) - if err != nil { - return err - } - *v = append(*v, parsed) - } - case *[]int: - for _, value := range values { - parsed, err := parseInt(value) - if err != nil { - return err - } - *v = append(*v, parsed) - } - case *[]float64: - for _, value := range values { - parsed, err := parseFloat(value) - if err != nil { - return err - } - *v = append(*v, parsed) - } - case *[]specid.ID: - for _, value := range values { - parsed, err := parseID(value) - if err != nil { - return err - } - *v = append(*v, parsed) - } - case *[]bool: - for _, value := range values { - parsed, err := parseBool(value) - if err != nil { - return err - } - *v = append(*v, parsed) - } - case *[]time.Time: - for _, value := range values { - parsed, err := parseTime(value) - if err != nil { - return err - } - *v = append(*v, parsed) - } - } - return err -} - type Params url.Values func New(r *http.Request) Params { @@ -461,3 +371,93 @@ func (p Params) GetFirstOrTime(or time.Time, keys ...string) time.Time { } return or } + +// some thin wrappers +// may be needed when cleaning up parse() below +func parseStr(in string) (string, error) { return in, nil } +func parseInt(in string) (int, error) { return strconv.Atoi(in) } +func parseFloat(in string) (float64, error) { return strconv.ParseFloat(in, 64) } +func parseID(in string) (specid.ID, error) { return specid.New(in) } +func parseBool(in string) (bool, error) { return strconv.ParseBool(in) } + +func parseTime(in string) (time.Time, error) { + ms, err := strconv.Atoi(in) + if err != nil { + return time.Time{}, err + } + ns := int64(ms) * 1e6 + return time.Unix(0, ns), nil +} + +func parse(values []string, i interface{}) error { + if len(values) == 0 { + return ErrNoValues + } + var err error + switch v := i.(type) { + // *T + case *string: + *v, err = parseStr(values[0]) + case *int: + *v, err = parseInt(values[0]) + case *float64: + *v, err = parseFloat(values[0]) + case *specid.ID: + *v, err = parseID(values[0]) + case *bool: + *v, err = parseBool(values[0]) + case *time.Time: + *v, err = parseTime(values[0]) + + // *[]T + case *[]string: + for _, value := range values { + parsed, err := parseStr(value) + if err != nil { + return err + } + *v = append(*v, parsed) + } + case *[]int: + for _, value := range values { + parsed, err := parseInt(value) + if err != nil { + return err + } + *v = append(*v, parsed) + } + case *[]float64: + for _, value := range values { + parsed, err := parseFloat(value) + if err != nil { + return err + } + *v = append(*v, parsed) + } + case *[]specid.ID: + for _, value := range values { + parsed, err := parseID(value) + if err != nil { + return err + } + *v = append(*v, parsed) + } + case *[]bool: + for _, value := range values { + parsed, err := parseBool(value) + if err != nil { + return err + } + *v = append(*v, parsed) + } + case *[]time.Time: + for _, value := range values { + parsed, err := parseTime(value) + if err != nil { + return err + } + *v = append(*v, parsed) + } + } + return err +}