package monstersirenfetch import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "slices" ) // SongResponse is the response of /api/song/%d. type SongResponse Response[Song] // Song holds the metadata of a song. // Fields marked with omitempty are only populated when the IsFull method returns true. type Song struct { CID StringInt `json:"cid"` Name string `json:"name"` AlbumCID StringInt `json:"albumCid"` SourceURL string `json:"sourceUrl,omitempty"` LyricURL string `json:"lyricUrl,omitempty"` MvURL string `json:"mvUrl,omitempty"` MvCoverURL string `json:"mvCoverUrl,omitempty"` Artists []string `json:"artists"` } // 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 // songNullable is [Song] with corresponding nullable string fields. type songNullable struct { CID StringInt `json:"cid"` Name string `json:"name"` AlbumCID StringInt `json:"albumCid"` SourceURL string `json:"sourceUrl"` LyricURL NullableString `json:"lyricUrl"` MvURL NullableString `json:"mvUrl"` MvCoverURL NullableString `json:"mvCoverUrl"` Artists []string `json:"artists"` } func (s *Song) MarshalJSON() (data []byte, err error) { buf := new(bytes.Buffer) e := json.NewEncoder(buf) e.SetEscapeHTML(false) if !s.IsFull() { err = e.Encode((*songDirect)(s)) data = buf.Bytes() return } return json.Marshal(&songNullable{s.CID, s.Name, s.AlbumCID, s.SourceURL, NullableString(s.LyricURL), NullableString(s.MvURL), NullableString(s.MvCoverURL), s.Artists}) }