From a8755c7208513be1be23ad0bcb9cc796300dcda5 Mon Sep 17 00:00:00 2001 From: Michael Zhang Date: Tue, 15 Dec 2020 23:53:26 -0600 Subject: [PATCH] initial --- beatmapsets.go | 95 ++++++++++++++++++++++++++++ go.mod | 5 ++ go.sum | 2 + models.go | 76 ++++++++++++++++++++++ osuapi.go | 167 +++++++++++++++++++++++++++++++++++++++++++++++++ users.go | 28 +++++++++ 6 files changed, 373 insertions(+) create mode 100644 beatmapsets.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 models.go create mode 100644 osuapi.go create mode 100644 users.go diff --git a/beatmapsets.go b/beatmapsets.go new file mode 100644 index 0000000..6e5a5b2 --- /dev/null +++ b/beatmapsets.go @@ -0,0 +1,95 @@ +package osuapiv2 + +import ( + "fmt" + "io" + "io/ioutil" + "net/url" + "os" +) + +func (api *Api) SearchBeatmaps(rankStatus string) (beatmapSearch BeatmapSearch, err error) { + values := url.Values{} + values.Set("s", rankStatus) + query := values.Encode() + url := "/beatmapsets/search?" + query + err = api.Request("GET", url, &beatmapSearch) + if err != nil { + return + } + + return +} + +func (api *Api) DownloadSingleBeatmap(beatmapId int, path string) (err error) { + url := fmt.Sprintf("https://osu.ppy.sh/osu/%d", beatmapId) + resp, err := api.httpClient.Get(url) + + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return + } + + _, err = io.Copy(file, resp.Body) + if err != nil { + return + } + return +} + +func (api *Api) GetBeatmapSet(beatmapSetId int) (beatmapSet Beatmapset, err error) { + url := fmt.Sprintf("/beatmapsets/%d", beatmapSetId) + err = api.Request("GET", url, &beatmapSet) + if err != nil { + return + } + + return +} + +func (api *Api) BeatmapsetDownload(beatmapSetId int) (path string, err error) { + url := fmt.Sprintf("/beatmapsets/%d/download", beatmapSetId) + resp, err := api.Request0("GET", url) + if err != nil { + return + } + + file, err := ioutil.TempFile(os.TempDir(), "beatmapsetDownload") + if err != nil { + return + } + + _, err = io.Copy(file, resp.Body) + if err != nil { + return + } + file.Close() + + path = file.Name() + return +} + +type GetBeatmapsetEventsOptions struct { + User string + Types []string +} + +func (api *Api) GetBeatmapsetEvents(opts *GetBeatmapsetEventsOptions) (events []BeatmapsetEvent, err error) { + values := url.Values{} + values.Set("user", opts.User) + query := values.Encode() + for _, t := range opts.Types { + query += "&types[]=" + t + } + url := "/beatmapsets/events?" + query + fmt.Println("URL IS", url) + + var reply BeatmapsetEvents + err = api.Request("GET", url, &reply) + if err != nil { + return + } + + events = reply.Events + return +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3c3e31d --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module osuapiv2 + +go 1.15 + +require golang.org/x/sync v0.0.0-20201207232520-09787c993a3a diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5f7eb37 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/models.go b/models.go new file mode 100644 index 0000000..c08d8b2 --- /dev/null +++ b/models.go @@ -0,0 +1,76 @@ +package osuapiv2 + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + CountryCode string `json:"country_code"` +} + +type Beatmapset struct { + ID int `json:"id"` + + Artist string `json:"artist"` + ArtistUnicode string `json:"artist_unicode"` + Title string `json:"title"` + TitleUnicode string `json:"title_unicode"` + Creator string `json:"creator"` + UserID int `json:"user_id"` + + Covers BeatmapCovers `json:"covers"` + Beatmaps []Beatmap `json:"beatmaps,omitempty"` + LastUpdated string `json:"last_updated,omitempty"` +} + +type Beatmap struct { + ID int `json:"id"` + DifficultyRating float64 `json:"difficulty_rating"` + DifficultyName string `json:"version"` +} + +type BeatmapCovers struct { + Cover string `json:"cover"` + Cover2x string `json:"cover@2x"` + Card string `json:"card"` + Card2x string `json:"card@2x"` + SlimCover string `json:"slimcover"` + SlimCover2x string `json:"slimcover@2x"` +} + +type Event struct { + CreatedAt string `json:"created_at"` + ID int `json:"id"` + Type string `json:"type"` + + Achievement EventAchievement `json:"achievement,omitempty"` + Beatmapset EventBeatmapset `json:"beatmapset,omitempty"` + + User EventUser `json:"user,omitempty"` +} + +type EventAchievement struct{} + +type EventBeatmapset struct { + Title string `json:"title"` + URL string `json:"url"` +} + +type EventUser struct { + Username string `json:"username"` + URL string `json:"url"` + PreviousUsername string `json:"previousUsername,omitempty"` +} + +type BeatmapSearch struct { + Beatmapsets []Beatmapset `json:"beatmapsets"` +} + +type BeatmapsetEvents struct { + Events []BeatmapsetEvent `json:"events"` +} + +type BeatmapsetEvent struct { + ID int `json:"id"` + Type string `json:"type"` + Beatmapset Beatmapset `json:"beatmapset"` + UserID int `json:"user_id"` +} diff --git a/osuapi.go b/osuapi.go new file mode 100644 index 0000000..5cce992 --- /dev/null +++ b/osuapi.go @@ -0,0 +1,167 @@ +package osuapiv2 + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/sync/semaphore" +) + +const BASE_URL = "https://osu.ppy.sh/api/v2" + +type Api struct { + httpClient *http.Client + lock *semaphore.Weighted + token string + expires time.Time + config *Config + + tokenLock sync.RWMutex + isFetchingToken bool +} + +type Config struct { + ClientId string + ClientSecret string +} + +func New(config *Config) *Api { + client := &http.Client{ + Timeout: 9 * time.Second, + } + + // want to cap at around 1000 requests a minute, OSU cap is 1200 + lock := semaphore.NewWeighted(1000) + + return &Api{ + httpClient: client, + lock: lock, + expires: time.Now(), + config: config, + } +} + +func (api *Api) Token() (token string, err error) { + if time.Now().Before(api.expires) { + token = api.token + return + } + + if api.isFetchingToken { + api.tokenLock.RLock() + token = api.token + api.tokenLock.RUnlock() + return + } + + api.tokenLock.Lock() + api.isFetchingToken = true + + data := fmt.Sprintf( + "client_id=%s&client_secret=%s&grant_type=client_credentials&scope=public", + api.config.ClientId, + api.config.ClientSecret, + ) + + resp, err := http.Post( + "https://osu.ppy.sh/oauth/token", + "application/x-www-form-urlencoded", + strings.NewReader(data), + ) + if err != nil { + return + } + + var osuToken OsuToken + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return + } + + err = json.Unmarshal(respBody, &osuToken) + if err != nil { + return + } + + log.Println("got new access token", osuToken.AccessToken[:12]+"...") + api.token = osuToken.AccessToken + api.expires = time.Now().Add(time.Duration(osuToken.ExpiresIn) * time.Second) + + token = api.token + api.tokenLock.Unlock() + return +} + +func (api *Api) Request0(action string, url string) (resp *http.Response, err error) { + err = api.lock.Acquire(context.TODO(), 1) + if err != nil { + return + } + apiUrl := BASE_URL + url + req, err := http.NewRequest(action, apiUrl, nil) + + token, err := api.Token() + if err != nil { + return + } + + req.Header.Add("Authorization", "Bearer "+token) + if err != nil { + return + } + + resp, err = api.httpClient.Do(req) + if err != nil { + return + } + + if resp.StatusCode != 200 { + var respBody []byte + respBody, err = ioutil.ReadAll(resp.Body) + if err != nil { + return + } + + err = fmt.Errorf("not 200: %s", string(respBody)) + return + } + + // release the lock after 1 minute + go func() { + time.Sleep(time.Minute) + api.lock.Release(1) + }() + return +} + +func (api *Api) Request(action string, url string, result interface{}) (err error) { + resp, err := api.Request0(action, url) + if err != nil { + return + } + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return + } + + err = json.Unmarshal(data, result) + if err != nil { + return + } + + return +} + +type OsuToken struct { + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` +} diff --git a/users.go b/users.go new file mode 100644 index 0000000..df4f534 --- /dev/null +++ b/users.go @@ -0,0 +1,28 @@ +package osuapiv2 + +import "fmt" + +func (api *Api) GetUser(userId string) (user User, err error) { + url := fmt.Sprintf("/users/%s", userId) + err = api.Request("GET", url, &user) + if err != nil { + return + } + + return +} + +func (api *Api) GetUserEvents(userId int, limit int, offset int) (events []Event, err error) { + url := fmt.Sprintf( + "/users/%d/recent_activity?limit=%d&offset=%d", + userId, + limit, + offset, + ) + err = api.Request("GET", url, &events) + if err != nil { + return + } + + return +}