From e3e23acee46cb2fdd46f5ae2448f3633cc1687f1 Mon Sep 17 00:00:00 2001 From: Yonah Date: Thu, 18 Sep 2025 19:25:17 +0900 Subject: [PATCH] album: extend struct for /api/album/%d/data This also includes tests against a sample response from the https://monster-siren.hypergryph.com/api/album/1030/data endpoint. Signed-off-by: Yonah --- album.go | 109 +++++++++++++++++++++++ album_test.go | 172 +++++++++++++++++++++++++++++++++++++ albums.go | 8 -- cmd/msrfetch/enrich.go | 36 +++++++- cmd/msrfetch/fetch.go | 15 +++- composite.go | 11 +-- generic.go | 14 +++ generic_test.go | 29 +++++++ song.go | 23 ++--- song_test.go | 73 +++++----------- testdata/album.data.json | 1 + testdata/album.detail.json | 1 + 12 files changed, 404 insertions(+), 88 deletions(-) create mode 100644 album.go create mode 100644 album_test.go create mode 100644 testdata/album.data.json create mode 100644 testdata/album.detail.json diff --git a/album.go b/album.go new file mode 100644 index 0000000..2a5ab43 --- /dev/null +++ b/album.go @@ -0,0 +1,109 @@ +package monstersirenfetch + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" +) + +// AlbumResponse is the response of /api/album/%d/data and /api/album/%d/detail. +type AlbumResponse Response[Album] + +// AlbumSong represents a minimal variant of [Song] embedded in the detail variant of [Album]. +type AlbumSong struct { + CID StringInt `json:"cid"` + Name string `json:"name"` + Artists []string `json:"artistes"` +} + +// Album represents the metadata of an album. +// Field availability is documented for each individual field. +type Album struct { + // CID is available for all variants of [Album]. + CID StringInt `json:"cid"` + // Name is available for all variants of [Album]. + Name string `json:"name"` + // Intro is available for data and detail variants of [Album]. + Intro string `json:"intro,omitempty"` + // Belong is available for data and detail variants of [Album]. + Belong string `json:"belong,omitempty"` + // CoverURL is available for all variants of [Album]. + CoverURL string `json:"coverUrl"` + // CoverDeURL is available for data and detail variants of [Album]. + CoverDeURL string `json:"coverDeUrl,omitempty"` + // Songs is available for the detail variant of [Album]. + Songs []AlbumSong `json:"songs,omitempty"` + // Artists is available for base and data variants of [Album]. + Artists []string `json:"artistes"` +} + +func (a *Album) IsFull() bool { return a.Belong != "" && len(a.Songs) > 0 } + +// Enrich populates the remaining fields of a non-full [Album]. +func (a *Album) Enrich(ctx context.Context, n Net) error { + if a == nil || a.IsFull() { + return os.ErrInvalid + } + + var v AlbumResponse + if body, _, err := n.Get(ctx, APIPrefix+"/album/"+a.CID.String()+"/detail"); 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 a.CID != v.Data.CID { + return &InconsistentEnrichError[StringInt]{"cid", a.CID, v.Data.CID} + } + if a.Name != v.Data.Name { + return &InconsistentEnrichError[string]{"name", a.Name, v.Data.Name} + } + if a.CoverURL != v.Data.CoverURL { + return &InconsistentEnrichError[string]{"coverUrl", a.CoverURL, v.Data.CoverURL} + } + if v.Data.Artists != nil { + return &InconsistentEnrichError[[]string]{"artists", nil, v.Data.Artists} + } + + v.Data.Artists = a.Artists + *a = v.Data + return nil +} + +// albumDirect is [Album] without its MarshalJSON method. +type albumDirect Album + +// albumNullable is [Album] with corresponding nullable string fields. +type albumNullable struct { + CID StringInt `json:"cid"` + Name string `json:"name"` + Intro NullableString `json:"intro"` + Belong NullableString `json:"belong"` + CoverURL string `json:"coverUrl"` + CoverDeURL NullableString `json:"coverDeUrl"` + Songs []AlbumSong `json:"songs,omitempty"` + Artists []string `json:"artistes"` +} + +func (a *Album) MarshalJSON() (data []byte, err error) { + buf := new(bytes.Buffer) + e := NewEncoder(buf) + + if a.Belong == "" { // this covers both data and detail variants + err = e.Encode((*albumDirect)(a)) + data = buf.Bytes() + return + } + + return json.Marshal(&albumNullable{a.CID, a.Name, + NullableString(a.Intro), NullableString(a.Belong), + a.CoverURL, NullableString(a.CoverDeURL), a.Songs, a.Artists}) +} diff --git a/album_test.go b/album_test.go new file mode 100644 index 0000000..f7fd4c8 --- /dev/null +++ b/album_test.go @@ -0,0 +1,172 @@ +package monstersirenfetch_test + +import ( + _ "embed" + "errors" + "os" + "reflect" + "testing" + + . "git.gensokyo.uk/yonah/monstersirenfetch" +) + +var ( + //go:embed testdata/album.data.json + albumDataJSON []byte + //go:embed testdata/album.detail.json + albumDetailJSON []byte +) + +func TestAlbum(t *testing.T) { + checkJSONRoundTrip(t, AlbumResponse{Data: Album{ + CID: 1030, + Name: "Speed of Light", + Intro: "在无人告知方向的黑夜里,独自以光速探寻正确的道路。 \n无论看待世界是简单,亦或是复杂,大地和天空始终让开始与结束相连。 \n《Speed of Light》,通过乐调铺陈的驰放与内敛感,希望能为您呈现超脱于现实的惬意想象空间。 \n愿您掌心的小小地图一直指引着前路。像光一样前行,如企鹅物流般永不停歇。", + Belong: "arknights", + CoverDeURL: "https://web.hycdn.cn/siren/pic/20210322/0bf0d84e08b57acd2455b412224ba8e8.jpg", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }}, albumDataJSON) +} + +func TestAlbumEnrich(t *testing.T) { + testCases := []struct { + name string + base Album + n Net + want Album + wantErr error + }{ + {"get", Album{ + CID: 1030, + Name: "Speed of Light", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, stubNet{}, Album{}, errors.New("url https://monster-siren.hypergryph.com/api/album/1030/detail requested, but is not present")}, + + {"invalid response", Album{ + CID: 1030, + Name: "Speed of Light", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, stubNet{ + makeAlbumPath(1030, true): []byte{0}, + }, Album{}, errors.Join(newSyntaxError("invalid character '\\x00' looking for beginning of value", 1))}, + + {"close", Album{ + CID: 1030, + Name: "Speed of Light", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, stubNetErrorCloser{ + makeAlbumPath(1030, true): albumDetailJSON, + }, Album{}, os.ErrInvalid}, + + {"inconsistent cid", Album{ + CID: 0xdeadbeef, + Name: "Speed of Light", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, stubNet{ + makeAlbumPath(0xdeadbeef, true): albumDetailJSON, + }, Album{}, &InconsistentEnrichError[StringInt]{ + Field: "cid", + Value: 0xdeadbeef, + NewValue: 1030, + }}, + + {"inconsistent name", Album{ + CID: 1030, + Name: "Speed of Light\x00", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, stubNet{ + makeAlbumPath(1030, true): albumDetailJSON, + }, Album{}, &InconsistentEnrichError[string]{ + Field: "name", + Value: "Speed of Light\x00", + NewValue: "Speed of Light", + }}, + + {"inconsistent coverUrl", Album{ + CID: 1030, + Name: "Speed of Light", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg\x00", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, stubNet{ + makeAlbumPath(1030, true): albumDetailJSON, + }, Album{}, &InconsistentEnrichError[string]{ + Field: "coverUrl", + Value: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg\x00", + NewValue: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + }}, + + {"unexpected artists", Album{ + CID: 1030, + Name: "Speed of Light", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, stubNet{ + makeAlbumPath(1030, true): albumDataJSON, + }, Album{}, &InconsistentEnrichError[[]string]{ + Field: "artists", + Value: nil, + NewValue: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }}, + + {"valid", Album{ + CID: 1030, + Name: "Speed of Light", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, stubNet{ + makeAlbumPath(1030, true): albumDetailJSON, + }, Album{ + CID: 1030, + Name: "Speed of Light", + Intro: "在无人告知方向的黑夜里,独自以光速探寻正确的道路。 \n无论看待世界是简单,亦或是复杂,大地和天空始终让开始与结束相连。 \n《Speed of Light》,通过乐调铺陈的驰放与内敛感,希望能为您呈现超脱于现实的惬意想象空间。 \n愿您掌心的小小地图一直指引着前路。像光一样前行,如企鹅物流般永不停歇。", + Belong: "arknights", + CoverDeURL: "https://web.hycdn.cn/siren/pic/20210322/0bf0d84e08b57acd2455b412224ba8e8.jpg", + CoverURL: "https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg", + Songs: []AlbumSong{ + {CID: 880374, Name: "Speed of Light", Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}}, + {CID: 125012, Name: "Speed of Light (Instrumental)", Artists: []string{"DJ Okawari"}}, + }, + Artists: []string{"塞壬唱片-MSR", "DJ Okawari"}, + }, nil}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + a := new(Album) + *a = tc.base + if err := a.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(a, &tc.want) { + t.Errorf("Enrich: %#v, want %#v", a, &tc.want) + } + }) + } + + t.Run("invalid", func(t *testing.T) { + t.Run("nil", func(t *testing.T) { + if err := (*Album)(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 := (&Album{Belong: "\x00", Songs: []AlbumSong{{}}}).Enrich(t.Context(), nil); !errors.Is(err, os.ErrInvalid) { + t.Errorf("Enrich: error = %v", err) + } + }) + }) + +} + +func makeAlbumPath(cid StringInt, detail bool) string { + suffix := "data" + if detail { + suffix = "detail" + } + return APIPrefix + "/album/" + cid.String() + "/" + suffix +} diff --git a/albums.go b/albums.go index b7f667c..1935a72 100644 --- a/albums.go +++ b/albums.go @@ -5,11 +5,3 @@ type AlbumsResponse Response[AlbumsData] // AlbumsData is the type of [AlbumsResponse] data. type AlbumsData []Album - -// Album represents the metadata of an album. -type Album struct { - CID StringInt `json:"cid"` - Name string `json:"name"` - CoverURL string `json:"coverUrl"` - Artists []string `json:"artistes"` -} diff --git a/cmd/msrfetch/enrich.go b/cmd/msrfetch/enrich.go index e0b18a0..25c5777 100644 --- a/cmd/msrfetch/enrich.go +++ b/cmd/msrfetch/enrich.go @@ -4,6 +4,7 @@ import ( "context" "flag" "log" + "slices" "git.gensokyo.uk/yonah/monstersirenfetch" ) @@ -36,8 +37,28 @@ func mustEnrich(ctx context.Context) { } else { n := new(netDirect) for _, ca := range c { - log.Printf("enriching album %s (%s)", ca.Name, ca.CID.String()) + if ca.Album == nil { + log.Fatal("albums contains nil") + } + + if !ca.Album.IsFull() { + if err = ca.Album.Enrich(ctx, n); err != nil { + log.Fatal(err) + } + log.Printf("enriched album %s (%s) with %d songs", ca.Album.CID.String(), ca.Album.Name, len(ca.Songs)) + } else { + log.Printf("skipped album %s (%s)", ca.Album.CID.String(), ca.Album.Name) + } + + // consistency check: for later validating enriched songs against flatten + flattenSongs := make([]monstersirenfetch.AlbumSong, 0, len(ca.Songs)) + for _, cs := range ca.Songs { + if cs == nil { + log.Fatal("songs contains nil") + } + flattenSongs = append(flattenSongs, monstersirenfetch.AlbumSong{CID: cs.CID, Name: cs.Name, Artists: cs.Artists}) + if !cs.IsFull() { if err = cs.Enrich(ctx, n); err != nil { log.Fatal(err) @@ -47,6 +68,19 @@ func mustEnrich(ctx context.Context) { log.Printf("skipped song %s: %s (%s)", cs.CID.String(), cs.SourceURL, cs.Name) } } + + // consistency check: enriched songs match flattened songs + slices.SortFunc(flattenSongs, func(a, b monstersirenfetch.AlbumSong) int { return int(a.CID - b.CID) }) + enrichSongs := make([]monstersirenfetch.AlbumSong, len(ca.Album.Songs)) + copy(enrichSongs, ca.Album.Songs) + slices.SortFunc(enrichSongs, func(a, b monstersirenfetch.AlbumSong) int { return int(a.CID - b.CID) }) + if !slices.EqualFunc(flattenSongs, enrichSongs, func(a monstersirenfetch.AlbumSong, b monstersirenfetch.AlbumSong) bool { + return a.CID == b.CID && a.Name == b.Name && slices.Equal(a.Artists, b.Artists) + }) { + log.Fatalf("album %s enrichment inconsistent with flatten state", ca.Album.Name) + } else { + log.Printf("validated %d songs associated with album %s", len(enrichSongs), ca.Album.CID.String()) + } } mustWriteJSON(flagOutputPath, c) log.Println("composite data written to", flagOutputPath) diff --git a/cmd/msrfetch/fetch.go b/cmd/msrfetch/fetch.go index bd5b8da..ff87716 100644 --- a/cmd/msrfetch/fetch.go +++ b/cmd/msrfetch/fetch.go @@ -32,6 +32,7 @@ func mustFetch(ctx context.Context) { const ( invalidContainsNil = "invalid composite data" + invalidNotEnriched = "this composite is not enriched" ) var urls []string @@ -39,17 +40,23 @@ func mustFetch(ctx context.Context) { if ca.Album == nil { log.Fatal(invalidContainsNil) } - if ca.CoverURL == "" { - log.Fatalf("album %s missing coverUrl", ca.CID.String()) + if !ca.Album.IsFull() { + log.Fatal(invalidNotEnriched) + } + if ca.Album.CoverURL == "" { + log.Fatalf("album %s missing coverUrl", ca.Album.CID.String()) + } + urls = append(urls, ca.Album.CoverURL) + if ca.Album.CoverDeURL != "" { + urls = append(urls, ca.Album.CoverDeURL) } - urls = append(urls, ca.CoverURL) for _, cs := range ca.Songs { if cs == nil { log.Fatal(invalidContainsNil) } if !cs.IsFull() { - log.Fatal("this composite is not enriched") + log.Fatal(invalidNotEnriched) } urls = append(urls, cs.SourceURL) diff --git a/composite.go b/composite.go index dedd8b1..c4b15d7 100644 --- a/composite.go +++ b/composite.go @@ -39,7 +39,8 @@ type CompositeAlbum struct { // Songs is a map of [Song.CID] to [Song]. Songs SongsMap `json:"songs"` - *Album + // Album is the underlying [Album]. + Album *Album `json:"data"` } type ( @@ -56,15 +57,15 @@ func (m CompositeAlbumsMap) String() string { } compAlbums := slices.Collect(maps.Values(m)) - slices.SortFunc(compAlbums, func(a, b CompositeAlbum) int { return int(a.CID - b.CID) }) + slices.SortFunc(compAlbums, func(a, b CompositeAlbum) int { return int(a.Album.CID - b.Album.CID) }) s := make([]string, len(compAlbums)) var buf strings.Builder for i, ca := range compAlbums { buf.WriteString( - "Album: " + ca.Name + " (" + ca.CID.String() + ")\n" + - "Cover: " + ca.CoverURL + "\n" + - "Artist(s): " + strings.Join(ca.Artists, ", ") + "\n" + + "Album: " + ca.Album.Name + " (" + ca.Album.CID.String() + ")\n" + + "Cover: " + ca.Album.CoverURL + "\n" + + "Artist(s): " + strings.Join(ca.Album.Artists, ", ") + "\n" + "Songs:\n") albumSongs := slices.Collect(maps.Values(ca.Songs)) diff --git a/generic.go b/generic.go index 91dacf2..1276fe9 100644 --- a/generic.go +++ b/generic.go @@ -3,6 +3,7 @@ package monstersirenfetch import ( "context" "encoding/json" + "fmt" "io" "strconv" "strings" @@ -12,6 +13,19 @@ const ( APIPrefix = "https://monster-siren.hypergryph.com/api" ) +// InconsistentEnrichError is returned by an Enrich method when a field is inconsistent +// between base data and enrichment data. +type InconsistentEnrichError[T any] struct { + Field string + Value T + NewValue T +} + +func (e *InconsistentEnrichError[T]) Error() string { + return "field " + e.Field + " inconsistent: " + + fmt.Sprintf("%v differs from %v", e.Value, e.NewValue) +} + // Net represents an abstraction over the [net] package. type Net interface { // Get makes a get request to url and returns the response body. diff --git a/generic_test.go b/generic_test.go index 4b3ccc8..bd49a31 100644 --- a/generic_test.go +++ b/generic_test.go @@ -2,7 +2,9 @@ package monstersirenfetch_test import ( "bytes" + "context" "encoding/json" + "fmt" "io" "os" "reflect" @@ -13,6 +15,13 @@ import ( . "git.gensokyo.uk/yonah/monstersirenfetch" ) +func TestInconsistentEnrichError(t *testing.T) { + const want = "field cid inconsistent: 48794 differs from 3735928559" + if got := (&InconsistentEnrichError[StringInt]{Field: "cid", Value: 48794, NewValue: 0xdeadbeef}).Error(); got != want { + t.Errorf("Error: %q, want %q", got, want) + } +} + func TestNullableString(t *testing.T) { checkJSONRoundTripM(t, []jsonRoundTripTestCase[NullableString]{ {"zero", nil, `null`, ""}, @@ -105,6 +114,26 @@ func checkJSONRoundTrip[T any](t *testing.T, v T, data []byte) { }) } +type stubNetErrorCloser stubNet + +func (n stubNetErrorCloser) Get(ctx context.Context, url string) (io.ReadCloser, int64, error) { + r, l, err := stubNet(n).Get(ctx, url) + if r != nil { + r = errorCloser{r} + } + return r, l, err +} + +type stubNet map[string][]byte + +func (n stubNet) Get(_ context.Context, url string) (io.ReadCloser, int64, error) { + if b, ok := n[url]; !ok { + return nil, -2, fmt.Errorf("url %s requested, but is not present", url) + } else { + return nopCloser{bytes.NewReader(b)}, int64(len(b)), nil + } +} + type errorCloser struct{ io.Reader } func (errorCloser) Close() error { return os.ErrInvalid } diff --git a/song.go b/song.go index 9d3ec94..46fff99 100644 --- a/song.go +++ b/song.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "os" "slices" ) @@ -29,17 +28,6 @@ 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() { @@ -60,16 +48,16 @@ func (s *Song) Enrich(ctx context.Context, n Net) error { // these should be unreachable unless the server malfunctions if s.CID != v.Data.CID { - return &InconsistentSongError[StringInt]{"cid", s.CID, v.Data.CID} + return &InconsistentEnrichError[StringInt]{"cid", s.CID, v.Data.CID} } if s.Name != v.Data.Name { - return &InconsistentSongError[string]{"name", s.Name, v.Data.Name} + return &InconsistentEnrichError[string]{"name", s.Name, v.Data.Name} } if s.AlbumCID != v.Data.AlbumCID { - return &InconsistentSongError[StringInt]{"albumCid", s.AlbumCID, v.Data.AlbumCID} + return &InconsistentEnrichError[StringInt]{"albumCid", s.AlbumCID, v.Data.AlbumCID} } if !slices.Equal(s.Artists, v.Data.Artists) { - return &InconsistentSongError[[]string]{"artists", s.Artists, v.Data.Artists} + return &InconsistentEnrichError[[]string]{"artists", s.Artists, v.Data.Artists} } *s = v.Data @@ -93,8 +81,7 @@ type songNullable struct { func (s *Song) MarshalJSON() (data []byte, err error) { buf := new(bytes.Buffer) - e := json.NewEncoder(buf) - e.SetEscapeHTML(false) + e := NewEncoder(buf) if !s.IsFull() { err = e.Encode((*songDirect)(s)) diff --git a/song_test.go b/song_test.go index 1909326..4b80611 100644 --- a/song_test.go +++ b/song_test.go @@ -1,16 +1,10 @@ package monstersirenfetch_test import ( - "bytes" - "context" _ "embed" "errors" - "fmt" - "io" "os" "reflect" - "strconv" - "strings" "testing" . "git.gensokyo.uk/yonah/monstersirenfetch" @@ -43,15 +37,15 @@ func TestSongEnrich(t *testing.T) { Name: "Warm and Small Light", AlbumCID: 6660, Artists: []string{"塞壬唱片-MSR"}, - }, stubNetSongEnrich{}, Song{}, errors.New("song cid 48794 requested, but is not present")}, + }, stubNet{}, Song{}, errors.New("url https://monster-siren.hypergryph.com/api/song/048794 requested, but is not present")}, {"invalid response", Song{ CID: 48794, Name: "Warm and Small Light", AlbumCID: 6660, Artists: []string{"塞壬唱片-MSR"}, - }, stubNetSongEnrich{ - 48794: []byte{0}, + }, stubNet{ + makeSongPath(48794): []byte{0}, }, Song{}, errors.Join(newSyntaxError("invalid character '\\x00' looking for beginning of value", 1))}, {"close", Song{ @@ -59,8 +53,8 @@ func TestSongEnrich(t *testing.T) { Name: "Warm and Small Light", AlbumCID: 6660, Artists: []string{"塞壬唱片-MSR"}, - }, stubNetSongEnrichErrorCloser{ - 48794: songJSON, + }, stubNetErrorCloser{ + makeSongPath(48794): songJSON, }, Song{}, os.ErrInvalid}, {"inconsistent cid", Song{ @@ -68,9 +62,9 @@ func TestSongEnrich(t *testing.T) { Name: "Warm and Small Light", AlbumCID: 6660, Artists: []string{"塞壬唱片-MSR"}, - }, stubNetSongEnrich{ - 0xdeadbeef: songJSON, - }, Song{}, &InconsistentSongError[StringInt]{ + }, stubNet{ + makeSongPath(0xdeadbeef): songJSON, + }, Song{}, &InconsistentEnrichError[StringInt]{ Field: "cid", Value: 0xdeadbeef, NewValue: 48794, @@ -81,9 +75,9 @@ func TestSongEnrich(t *testing.T) { Name: "Warm and Small Light\x00", AlbumCID: 6660, Artists: []string{"塞壬唱片-MSR"}, - }, stubNetSongEnrich{ - 48794: songJSON, - }, Song{}, &InconsistentSongError[string]{ + }, stubNet{ + makeSongPath(48794): songJSON, + }, Song{}, &InconsistentEnrichError[string]{ Field: "name", Value: "Warm and Small Light\x00", NewValue: "Warm and Small Light", @@ -94,9 +88,9 @@ func TestSongEnrich(t *testing.T) { Name: "Warm and Small Light", AlbumCID: -1, Artists: []string{"塞壬唱片-MSR"}, - }, stubNetSongEnrich{ - 48794: songJSON, - }, Song{}, &InconsistentSongError[StringInt]{ + }, stubNet{ + makeSongPath(48794): songJSON, + }, Song{}, &InconsistentEnrichError[StringInt]{ Field: "albumCid", Value: -1, NewValue: 6660, @@ -107,9 +101,9 @@ func TestSongEnrich(t *testing.T) { Name: "Warm and Small Light", AlbumCID: 6660, Artists: []string{"塞壬唱片-MSR", "\x00"}, - }, stubNetSongEnrich{ - 48794: songJSON, - }, Song{}, &InconsistentSongError[[]string]{ + }, stubNet{ + makeSongPath(48794): songJSON, + }, Song{}, &InconsistentEnrichError[[]string]{ Field: "artists", Value: []string{"塞壬唱片-MSR", "\x00"}, NewValue: []string{"塞壬唱片-MSR"}, @@ -120,8 +114,8 @@ func TestSongEnrich(t *testing.T) { Name: "Warm and Small Light", AlbumCID: 6660, Artists: []string{"塞壬唱片-MSR"}, - }, stubNetSongEnrich{ - 48794: songJSON, + }, stubNet{ + makeSongPath(48794): songJSON, }, Song{ CID: 48794, Name: "Warm and Small Light", @@ -157,33 +151,8 @@ func TestSongEnrich(t *testing.T) { } }) }) - - 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, int64, error) { - r, l, err := stubNetSongEnrich(n).Get(ctx, url) - if r != nil { - r = errorCloser{r} - } - return r, l, err -} - -type stubNetSongEnrich map[StringInt][]byte - -func (n stubNetSongEnrich) Get(_ context.Context, url string) (io.ReadCloser, int64, error) { - if i, err := strconv.Atoi(strings.TrimPrefix(url, APIPrefix+"/song/")); err != nil { - return nil, -2, err - } else if b, ok := n[StringInt(i)]; !ok { - return nil, -2, fmt.Errorf("song cid %d requested, but is not present", i) - } else { - return nopCloser{bytes.NewReader(b)}, int64(len(b)), nil - } +func makeSongPath(cid StringInt) string { + return APIPrefix + "/song/" + cid.String() } diff --git a/testdata/album.data.json b/testdata/album.data.json new file mode 100644 index 0000000..006ec31 --- /dev/null +++ b/testdata/album.data.json @@ -0,0 +1 @@ +{"code":0,"msg":"","data":{"cid":"1030","name":"Speed of Light","intro":"在无人告知方向的黑夜里,独自以光速探寻正确的道路。 \n无论看待世界是简单,亦或是复杂,大地和天空始终让开始与结束相连。 \n《Speed of Light》,通过乐调铺陈的驰放与内敛感,希望能为您呈现超脱于现实的惬意想象空间。 \n愿您掌心的小小地图一直指引着前路。像光一样前行,如企鹅物流般永不停歇。","belong":"arknights","coverUrl":"https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg","coverDeUrl":"https://web.hycdn.cn/siren/pic/20210322/0bf0d84e08b57acd2455b412224ba8e8.jpg","artistes":["塞壬唱片-MSR","DJ Okawari"]}} \ No newline at end of file diff --git a/testdata/album.detail.json b/testdata/album.detail.json new file mode 100644 index 0000000..e14a8a1 --- /dev/null +++ b/testdata/album.detail.json @@ -0,0 +1 @@ +{"code":0,"msg":"","data":{"cid":"1030","name":"Speed of Light","intro":"在无人告知方向的黑夜里,独自以光速探寻正确的道路。 \n无论看待世界是简单,亦或是复杂,大地和天空始终让开始与结束相连。 \n《Speed of Light》,通过乐调铺陈的驰放与内敛感,希望能为您呈现超脱于现实的惬意想象空间。 \n愿您掌心的小小地图一直指引着前路。像光一样前行,如企鹅物流般永不停歇。","belong":"arknights","coverUrl":"https://web.hycdn.cn/siren/pic/20210322/56cbcd1d0093d8ee8ee22bf6d68ab4a6.jpg","coverDeUrl":"https://web.hycdn.cn/siren/pic/20210322/0bf0d84e08b57acd2455b412224ba8e8.jpg","songs":[{"cid":"880374","name":"Speed of Light","artistes":["塞壬唱片-MSR","DJ Okawari"]},{"cid":"125012","name":"Speed of Light (Instrumental)","artistes":["DJ Okawari"]}]}} \ No newline at end of file