From bb1164d08178362e90b967262e5c2363577dcb7e Mon Sep 17 00:00:00 2001 From: Yonah Date: Thu, 18 Sep 2025 04:10:38 +0900 Subject: [PATCH] song: enrich metadata from API This should be the last of the API interactions we need. Signed-off-by: Yonah --- composite.go | 4 +- generic.go | 13 ++++ generic_test.go | 10 +++ song.go | 52 +++++++++++++++ song_test.go | 170 +++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 245 insertions(+), 4 deletions(-) diff --git a/composite.go b/composite.go index 1b26650..dedd8b1 100644 --- a/composite.go +++ b/composite.go @@ -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 diff --git a/generic.go b/generic.go index 63c4644..12cd880 100644 --- a/generic.go +++ b/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"` diff --git a/generic_test.go b/generic_test.go index 7b93e16..385cb76 100644 --- a/generic_test.go +++ b/generic_test.go @@ -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 } diff --git a/song.go b/song.go index 31faae3..1c0ad35 100644 --- a/song.go +++ b/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 diff --git a/song_test.go b/song_test.go index 5b13451..83f1dbb 100644 --- a/song_test.go +++ b/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"}, + 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 + } +}