song: enrich metadata from API
This should be the last of the API interactions we need. Signed-off-by: Yonah <contrib@gensokyo.uk>
This commit is contained in:
parent
d4789d958b
commit
bb1164d081
@ -34,9 +34,9 @@ func (e *SongConflictError) Error() string {
|
||||
"current song " + e.Current.Name
|
||||
}
|
||||
|
||||
// CompositeAlbum represents an [Album] with a collection of its associated identifier-only [Song].
|
||||
// CompositeAlbum represents an [Album] with a collection of its associated [Song].
|
||||
type CompositeAlbum struct {
|
||||
// Songs is a map of [Song.CID] to identifier-only [Song].
|
||||
// Songs is a map of [Song.CID] to [Song].
|
||||
Songs SongsMap `json:"songs"`
|
||||
|
||||
*Album
|
||||
|
13
generic.go
13
generic.go
@ -1,11 +1,24 @@
|
||||
package monstersirenfetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
APIPrefix = "https://monster-siren.hypergryph.com/api"
|
||||
)
|
||||
|
||||
// Net represents an abstraction over the [net] package.
|
||||
type Net interface {
|
||||
// Get makes a get request to url and returns the response body.
|
||||
// The caller must close the body after it finishes reading from it.
|
||||
Get(ctx context.Context, url string) (body io.ReadCloser, err error)
|
||||
}
|
||||
|
||||
// Response is a generic API response.
|
||||
type Response[T any] struct {
|
||||
Code int `json:"code"`
|
||||
|
@ -3,6 +3,8 @@ package monstersirenfetch_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
@ -105,3 +107,11 @@ func checkJSONRoundTrip[T any](t *testing.T, v T, data []byte) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type errorCloser struct{ io.Reader }
|
||||
|
||||
func (errorCloser) Close() error { return os.ErrInvalid }
|
||||
|
||||
type nopCloser struct{ io.Reader }
|
||||
|
||||
func (nopCloser) Close() error { return nil }
|
||||
|
52
song.go
52
song.go
@ -2,7 +2,12 @@ package monstersirenfetch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// SongResponse is the response of /api/song/%d.
|
||||
@ -24,6 +29,53 @@ type Song struct {
|
||||
// IsFull returns whether the metadata held by [Song] is considered full (originating from a [SongResponse]).
|
||||
func (s *Song) IsFull() bool { return s.SourceURL != "" }
|
||||
|
||||
type InconsistentSongError[T any] struct {
|
||||
Field string
|
||||
Value T
|
||||
NewValue T
|
||||
}
|
||||
|
||||
func (e *InconsistentSongError[T]) Error() string {
|
||||
return "field " + e.Field + " inconsistent: " +
|
||||
fmt.Sprintf("%v differs from %v", e.Value, e.NewValue)
|
||||
}
|
||||
|
||||
// Enrich populates the remaining fields of a non-full [Song].
|
||||
func (s *Song) Enrich(ctx context.Context, n Net) error {
|
||||
if s == nil || s.IsFull() {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
var v SongResponse
|
||||
if body, err := n.Get(ctx, APIPrefix+"/song/"+s.CID.String()); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err = json.NewDecoder(body).Decode(&v); err != nil {
|
||||
return errors.Join(body.Close(), err)
|
||||
}
|
||||
if err = body.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// these should be unreachable unless the server malfunctions
|
||||
if s.CID != v.Data.CID {
|
||||
return &InconsistentSongError[StringInt]{"cid", s.CID, v.Data.CID}
|
||||
}
|
||||
if s.Name != v.Data.Name {
|
||||
return &InconsistentSongError[string]{"name", s.Name, v.Data.Name}
|
||||
}
|
||||
if s.AlbumCID != v.Data.AlbumCID {
|
||||
return &InconsistentSongError[StringInt]{"albumCid", s.AlbumCID, v.Data.AlbumCID}
|
||||
}
|
||||
if !slices.Equal(s.Artists, v.Data.Artists) {
|
||||
return &InconsistentSongError[[]string]{"artists", s.Artists, v.Data.Artists}
|
||||
}
|
||||
|
||||
*s = v.Data
|
||||
return nil
|
||||
}
|
||||
|
||||
// songDirect is [Song] without its MarshalJSON method.
|
||||
type songDirect Song
|
||||
|
||||
|
168
song_test.go
168
song_test.go
@ -1,7 +1,16 @@
|
||||
package monstersirenfetch_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "git.gensokyo.uk/yonah/monstersirenfetch"
|
||||
@ -17,7 +26,164 @@ func TestSong(t *testing.T) {
|
||||
AlbumCID: 6660,
|
||||
SourceURL: "https://res01.hycdn.cn/04ce5de54bb52eb85008644d541d40fa/68CA0442/siren/audio/20240709/a7f650238eaefc9c30a9627d7f78d819.wav",
|
||||
LyricURL: "https://web.hycdn.cn/siren/lyric/20240709/4a10c70629b68a187fdbef4a27bd32d8.lrc",
|
||||
MvURL: "", MvCoverURL: "",
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}}, songJSON)
|
||||
}
|
||||
|
||||
func TestSongEnrich(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
base Song
|
||||
n Net
|
||||
want Song
|
||||
wantErr error
|
||||
}{
|
||||
{"get", Song{
|
||||
CID: 48794,
|
||||
Name: "Warm and Small Light",
|
||||
AlbumCID: 6660,
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}, stubNetSongEnrich{}, Song{}, errors.New("song cid 48794 requested, but is not present")},
|
||||
|
||||
{"invalid response", Song{
|
||||
CID: 48794,
|
||||
Name: "Warm and Small Light",
|
||||
AlbumCID: 6660,
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}, stubNetSongEnrich{
|
||||
48794: []byte{0},
|
||||
}, Song{}, errors.Join(newSyntaxError("invalid character '\\x00' looking for beginning of value", 1))},
|
||||
|
||||
{"close", Song{
|
||||
CID: 48794,
|
||||
Name: "Warm and Small Light",
|
||||
AlbumCID: 6660,
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}, stubNetSongEnrichErrorCloser{
|
||||
48794: songJSON,
|
||||
}, Song{}, os.ErrInvalid},
|
||||
|
||||
{"inconsistent cid", Song{
|
||||
CID: 0xdeadbeef,
|
||||
Name: "Warm and Small Light",
|
||||
AlbumCID: 6660,
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}, stubNetSongEnrich{
|
||||
0xdeadbeef: songJSON,
|
||||
}, Song{}, &InconsistentSongError[StringInt]{
|
||||
Field: "cid",
|
||||
Value: 0xdeadbeef,
|
||||
NewValue: 48794,
|
||||
}},
|
||||
|
||||
{"inconsistent name", Song{
|
||||
CID: 48794,
|
||||
Name: "Warm and Small Light\x00",
|
||||
AlbumCID: 6660,
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}, stubNetSongEnrich{
|
||||
48794: songJSON,
|
||||
}, Song{}, &InconsistentSongError[string]{
|
||||
Field: "name",
|
||||
Value: "Warm and Small Light\x00",
|
||||
NewValue: "Warm and Small Light",
|
||||
}},
|
||||
|
||||
{"inconsistent albumCid", Song{
|
||||
CID: 48794,
|
||||
Name: "Warm and Small Light",
|
||||
AlbumCID: -1,
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}, stubNetSongEnrich{
|
||||
48794: songJSON,
|
||||
}, Song{}, &InconsistentSongError[StringInt]{
|
||||
Field: "albumCid",
|
||||
Value: -1,
|
||||
NewValue: 6660,
|
||||
}},
|
||||
|
||||
{"inconsistent artists", Song{
|
||||
CID: 48794,
|
||||
Name: "Warm and Small Light",
|
||||
AlbumCID: 6660,
|
||||
Artists: []string{"塞壬唱片-MSR", "\x00"},
|
||||
}, stubNetSongEnrich{
|
||||
48794: songJSON,
|
||||
}, Song{}, &InconsistentSongError[[]string]{
|
||||
Field: "artists",
|
||||
Value: []string{"塞壬唱片-MSR", "\x00"},
|
||||
NewValue: []string{"塞壬唱片-MSR"},
|
||||
}},
|
||||
|
||||
{"valid", Song{
|
||||
CID: 48794,
|
||||
Name: "Warm and Small Light",
|
||||
AlbumCID: 6660,
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}, stubNetSongEnrich{
|
||||
48794: songJSON,
|
||||
}, Song{
|
||||
CID: 48794,
|
||||
Name: "Warm and Small Light",
|
||||
AlbumCID: 6660,
|
||||
SourceURL: "https://res01.hycdn.cn/04ce5de54bb52eb85008644d541d40fa/68CA0442/siren/audio/20240709/a7f650238eaefc9c30a9627d7f78d819.wav",
|
||||
LyricURL: "https://web.hycdn.cn/siren/lyric/20240709/4a10c70629b68a187fdbef4a27bd32d8.lrc",
|
||||
MvURL: "", MvCoverURL: "",
|
||||
Artists: []string{"塞壬唱片-MSR"},
|
||||
}, nil},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := new(Song)
|
||||
*s = tc.base
|
||||
if err := s.Enrich(t.Context(), tc.n); !reflect.DeepEqual(err, tc.wantErr) {
|
||||
t.Errorf("Enrich: error = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
if tc.wantErr == nil && !reflect.DeepEqual(s, &tc.want) {
|
||||
t.Errorf("Enrich: %#v, want %#v", s, &tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
t.Run("nil", func(t *testing.T) {
|
||||
if err := (*Song)(nil).Enrich(t.Context(), nil); !errors.Is(err, os.ErrInvalid) {
|
||||
t.Errorf("Enrich: error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("full", func(t *testing.T) {
|
||||
if err := (&Song{SourceURL: "\x00"}).Enrich(t.Context(), nil); !errors.Is(err, os.ErrInvalid) {
|
||||
t.Errorf("Enrich: error = %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("error", func(t *testing.T) {
|
||||
const want = "field cid inconsistent: 48794 differs from 3735928559"
|
||||
if got := (&InconsistentSongError[StringInt]{Field: "cid", Value: 48794, NewValue: 0xdeadbeef}).Error(); got != want {
|
||||
t.Errorf("Error: %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type stubNetSongEnrichErrorCloser stubNetSongEnrich
|
||||
|
||||
func (n stubNetSongEnrichErrorCloser) Get(ctx context.Context, url string) (io.ReadCloser, error) {
|
||||
r, err := stubNetSongEnrich(n).Get(ctx, url)
|
||||
if r != nil {
|
||||
r = errorCloser{r}
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
type stubNetSongEnrich map[StringInt][]byte
|
||||
|
||||
func (n stubNetSongEnrich) Get(_ context.Context, url string) (io.ReadCloser, error) {
|
||||
if i, err := strconv.Atoi(strings.TrimPrefix(url, APIPrefix+"/song/")); err != nil {
|
||||
return nil, err
|
||||
} else if b, ok := n[StringInt(i)]; !ok {
|
||||
return nil, fmt.Errorf("song cid %d requested, but is not present", i)
|
||||
} else {
|
||||
return nopCloser{bytes.NewReader(b)}, nil
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user