├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── approved_status.go ├── client.go ├── client_test.go ├── enums_test.go ├── genre.go ├── get_beatmaps.go ├── get_match.go ├── get_replay.go ├── get_scores.go ├── get_user.go ├── get_user_best.go ├── get_user_recent.go ├── go.mod ├── language.go ├── mode.go ├── mods.go ├── mods_test.go ├── mysql_date.go ├── osubool.go └── rate_limit.go /.gitignore: -------------------------------------------------------------------------------- 1 | osukey.txt 2 | *.exe 3 | xd.out 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.6 5 | - tip 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 Howl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-osuapi [![docs](https://godoc.org/github.com/thehowl/go-osuapi?status.svg)](https://godoc.org/github.com/thehowl/go-osuapi) [![Build Status](https://travis-ci.org/thehowl/go-osuapi.svg?branch=master)](https://travis-ci.org/thehowl/go-osuapi) [![Go Report Card](https://goreportcard.com/badge/github.com/thehowl/go-osuapi)](https://goreportcard.com/report/github.com/thehowl/go-osuapi) 2 | 3 | go-osuapi is a Go package to retrieve data from the osu! API. 4 | 5 | ## Getting started 6 | 7 | Everything is (more or less) well-documented at [godoc](https://godoc.org/github.com/thehowl/go-osuapi) - the methods that interest you most are probably those under [Client](https://godoc.org/github.com/thehowl/go-osuapi#Client). Also, [client_test.go](client_test.go) contains loads of examples on how you can use the package. If you still want to have an example to simply copypaste and then get straight to coding, well, there you go! 8 | 9 | ```go 10 | package main 11 | 12 | import ( 13 | "fmt" 14 | osuapi "github.com/thehowl/go-osuapi" 15 | ) 16 | 17 | func main() { 18 | c := osuapi.NewClient("Your API key https://osu.ppy.sh/p/api") 19 | beatmaps, err := c.GetBeatmaps(osuapi.GetBeatmapsOpts{ 20 | BeatmapSetID: 332532, 21 | }) 22 | if err != nil { 23 | fmt.Printf("An error occurred: %v\n", err) 24 | return 25 | } 26 | for _, beatmap := range beatmaps { 27 | fmt.Printf("%s - %s [%s] https://osu.ppy.sh/b/%d\n", beatmap.Artist, beatmap.Title, beatmap.DiffName, beatmap.BeatmapID) 28 | } 29 | } 30 | ``` 31 | 32 | Please note that if you actually want to use this, you should consider vendoring 33 | this using the [dep tool](https://github.com/golang/dep), so that your code 34 | keeps working regardless of any change we might do in this repository. 35 | 36 | ## I want more than that to explore how it works! 37 | 38 | I've made [whosu](https://github.com/thehowl/whosu) for that purpose. Check it out. 39 | 40 | ## Contributing 41 | 42 | Contributions are welcome! Here's what you need to know: 43 | 44 | * Always `go fmt` your code. 45 | * If you're writing a big and useful feature, make sure to appropriately write tests! 46 | -------------------------------------------------------------------------------- /approved_status.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import "strconv" 4 | 5 | // Approved statuses. 6 | const ( 7 | StatusGraveyard ApprovedStatus = iota - 2 8 | StatusWIP 9 | StatusPending 10 | StatusRanked 11 | StatusApproved 12 | StatusQualified 13 | StatusLoved 14 | ) 15 | 16 | // ApprovedStatus - also known as ranked status - is the status of a beatmap. 17 | // Yeah, no shit, I know. It tells whether the beatmap is ranked, qualified, 18 | // graveyarded or other memes. 19 | type ApprovedStatus int 20 | 21 | var approvedStatusesString = [...]string{ 22 | "graveyard", 23 | "WIP", 24 | "pending", 25 | "ranked", 26 | "approved", 27 | "qualified", 28 | "loved", 29 | } 30 | 31 | func (a ApprovedStatus) String() string { 32 | if a >= -2 && int(a)+2 < len(approvedStatusesString) { 33 | return approvedStatusesString[a+2] 34 | } 35 | return strconv.Itoa(int(a)) 36 | } 37 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | // Client is an osu! API client that is able to make API requests. 12 | type Client struct { 13 | client *http.Client 14 | key string 15 | } 16 | 17 | // NewClient generates a new Client based on an API key. 18 | func NewClient(key string) *Client { 19 | c := &Client{&http.Client{}, key} 20 | return c 21 | } 22 | 23 | func (c Client) makerq(endpoint string, queryString url.Values) ([]byte, error) { 24 | queryString.Set("k", c.key) 25 | req, err := http.NewRequest("GET", "https://osu.ppy.sh/api/"+endpoint+"?"+queryString.Encode(), nil) 26 | if err != nil { 27 | return nil, err 28 | } 29 | // if we are rate limiting requests, then wait before making request 30 | if requestsAvailable != nil { 31 | <-requestsAvailable 32 | } 33 | resp, err := c.client.Do(req) 34 | if err != nil { 35 | return nil, err 36 | } 37 | data, err := ioutil.ReadAll(resp.Body) 38 | defer resp.Body.Close() 39 | return data, err 40 | } 41 | 42 | type testResponse struct { 43 | Error string `json:"error"` 44 | } 45 | 46 | // Test makes sure the API is working (and the API key is valid). 47 | func (c Client) Test() error { 48 | resp, err := c.makerq("get_user", url.Values{ 49 | "u": []string{ 50 | "2", 51 | }, 52 | }) 53 | if err != nil { 54 | return err 55 | } 56 | var tr testResponse 57 | err = json.Unmarshal(resp, &tr) 58 | // Ignore cannot unmarshal stuff 59 | if err != nil && err.Error() != "json: cannot unmarshal array into Go value of type osuapi.testResponse" { 60 | return err 61 | } 62 | if tr.Error != "" { 63 | return errors.New("osuapi: " + tr.Error) 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | var apiKey string 10 | 11 | func TestSetKey(t *testing.T) { 12 | apiKey = os.Getenv("OSU_API_KEY") 13 | if apiKey == "" { 14 | t.Fatal("OSU_API_KEY was not set. All tests that require a connection to the osu! server will fail.") 15 | } 16 | } 17 | 18 | // ck checks that the apikey is set, if not it immediately fails the test. 19 | func ck(t *testing.T) { 20 | if apiKey == "" { 21 | t.Skip("no api key") 22 | } 23 | } 24 | 25 | // fe returns err.Error(), with the API key removed 26 | func fe(err error) string { 27 | return strings.Replace(err.Error(), apiKey, "xxxxxx", -1) 28 | } 29 | 30 | func TestTestClient(t *testing.T) { 31 | ck(t) 32 | c := NewClient(apiKey) 33 | err := c.Test() 34 | if err != nil { 35 | t.Fatal(fe(err)) 36 | } 37 | } 38 | 39 | func TestGetUser(t *testing.T) { 40 | ck(t) 41 | c := NewClient(apiKey) 42 | _, err := c.GetUser(GetUserOpts{ 43 | Username: "Loctav", 44 | Mode: ModeTaiko, 45 | EventDays: 4, 46 | }) 47 | if err != nil && err != ErrNoSuchUser { 48 | t.Fatal(fe(err)) 49 | } 50 | } 51 | 52 | func TestGetBeatmaps(t *testing.T) { 53 | ck(t) 54 | c := NewClient(apiKey) 55 | _, err := c.GetBeatmaps(GetBeatmapsOpts{ 56 | BeatmapSetID: 332532, 57 | }) 58 | if err != nil { 59 | t.Fatal(fe(err)) 60 | } 61 | } 62 | 63 | func TestGetScores(t *testing.T) { 64 | ck(t) 65 | c := NewClient(apiKey) 66 | _, err := c.GetScores(GetScoresOpts{ 67 | BeatmapID: 736213, 68 | }) 69 | if err != nil { 70 | t.Fatal(fe(err)) 71 | } 72 | } 73 | 74 | func TestGetUserBest(t *testing.T) { 75 | ck(t) 76 | c := NewClient(apiKey) 77 | _, err := c.GetUserBest(GetUserScoresOpts{ 78 | UserID: 2, 79 | }) 80 | if err != nil { 81 | t.Fatal(fe(err)) 82 | } 83 | } 84 | 85 | func TestGetUserRecent(t *testing.T) { 86 | ck(t) 87 | c := NewClient(apiKey) 88 | _, err := c.GetUserRecent(GetUserScoresOpts{ 89 | Username: "Cookiezi", 90 | }) 91 | if err != nil { 92 | t.Fatal(fe(err)) 93 | } 94 | } 95 | 96 | func TestGetMatch(t *testing.T) { 97 | ck(t) 98 | c := NewClient(apiKey) 99 | _, err := c.GetMatch(20138460) 100 | if err != nil { 101 | t.Fatal(fe(err)) 102 | } 103 | } 104 | 105 | func TestGetReplay(t *testing.T) { 106 | ck(t) 107 | c := NewClient(apiKey) 108 | replayReader, err := c.GetReplay(GetReplayOpts{ 109 | Username: "rrtyui", 110 | BeatmapID: 131891, 111 | }) 112 | if err != nil { 113 | t.Fatal(fe(err)) 114 | } 115 | d := make([]byte, 16) 116 | _, err = replayReader.Read(d) 117 | if err != nil { 118 | t.Fatal(fe(err)) 119 | } 120 | t.Logf("rrtyui on the big black: %x", d) 121 | } 122 | -------------------------------------------------------------------------------- /enums_test.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // these tests are really just here to bring the coverage up a bit 8 | 9 | func TestGenre(t *testing.T) { 10 | if GenreElectronic.String() != "electronic" { 11 | t.Fatal("expected GenreElectronic.String() to return 'electronic', got", GenreElectronic.String(), "instead") 12 | } 13 | } 14 | func TestGenreOOB(t *testing.T) { 15 | if Genre(14910).String() != "14910" { 16 | t.Fatal("expected Genre(14910).String() to return '14910', got", Genre(14910).String(), "instead") 17 | } 18 | } 19 | 20 | func TestApprovedStatus(t *testing.T) { 21 | if StatusGraveyard.String() != "graveyard" { 22 | t.Fatal("expected StatusGraveyard.String() to return 'graveyard', got", StatusGraveyard.String(), "instead") 23 | } 24 | } 25 | func TestApprovedStatusOOB(t *testing.T) { 26 | if ApprovedStatus(-41).String() != "-41" { 27 | t.Fatal("expected ApprovedStatus(-41).String() to return '-41', got", ApprovedStatus(-41).String(), "instead") 28 | } 29 | } 30 | 31 | func TestLanguage(t *testing.T) { 32 | if LanguageChinese.String() != "Chinese" { 33 | t.Fatal("expected LanguageChinese.String() to return 'Chinese', got", LanguageChinese.String(), "instead") 34 | } 35 | } 36 | func TestLanguageOOB(t *testing.T) { 37 | if Language(1337).String() != "1337" { 38 | t.Fatal("expected Language(1337).String() to return '1337', got", Language(1337).String(), "instead") 39 | } 40 | } 41 | 42 | func TestMode(t *testing.T) { 43 | if ModeOsuMania.String() != "osu!mania" { 44 | t.Fatal("expected ModeOsuMania.String() to return 'osu!mania', got", ModeOsuMania.String(), "instead") 45 | } 46 | } 47 | func TestModeOOB(t *testing.T) { 48 | if Mode(-414141).String() != "-414141" { 49 | t.Fatal("expected Mode(-414141).String() to return '-414141', got", Mode(1337).String(), "instead") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /genre.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import "strconv" 4 | 5 | // Genres 6 | const ( 7 | GenreAny Genre = iota 8 | GenreUnspecified 9 | GenreVideoGame 10 | GenreAnime 11 | GenreRock 12 | GenrePop 13 | GenreOther 14 | GenreNovelty 15 | GenreHipHop Genre = iota + 1 // there's no 8, so we must manually increment it by one 16 | GenreElectronic 17 | ) 18 | 19 | // Genre is the genre of a beatmap's song. 20 | type Genre int 21 | 22 | var genreString = [...]string{ 23 | "any", 24 | "unspecified", 25 | "video game", 26 | "anime", 27 | "rock", 28 | "pop", 29 | "other", 30 | "novelty", 31 | "8", 32 | "hip hop", 33 | "electronic", 34 | } 35 | 36 | func (g Genre) String() string { 37 | if g >= 0 && int(g) < len(genreString) { 38 | return genreString[g] 39 | } 40 | return strconv.Itoa(int(g)) 41 | } 42 | -------------------------------------------------------------------------------- /get_beatmaps.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "encoding/json" 5 | "net/url" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | // GetBeatmapsOpts is a struct containing the GET query string parameters to an 11 | // /api/get_beatmaps request. 12 | type GetBeatmapsOpts struct { 13 | Since *time.Time 14 | BeatmapSetID int 15 | BeatmapID int 16 | // If both UserID and Username are set, UserID will be used. 17 | UserID int 18 | Username string 19 | // Using a pointer because we MUST provide a way to make this parameter 20 | // optional, and it should be optional by default. This is because simply 21 | // doing != 0 won't work, because it wouldn't allow filtering with only 22 | // osu!std maps, and setting m=0 and not setting it it all makes the 23 | // difference between only allowing STD beatmaps and allowing beatmaps 24 | // from all modes. 25 | // Simply doing &osuapi.ModeOsuMania (or similiar) should do the trick, 26 | // should you need to use this field. 27 | // God was this comment long. 28 | Mode *Mode 29 | IncludeConverted bool 30 | BeatmapHash string 31 | Limit int 32 | } 33 | 34 | // Beatmap is an osu! beatmap. 35 | type Beatmap struct { 36 | BeatmapSetID int `json:"beatmapset_id,string"` 37 | BeatmapID int `json:"beatmap_id,string"` 38 | Approved ApprovedStatus `json:"approved,string"` 39 | TotalLength int `json:"total_length,string"` 40 | HitLength int `json:"hit_length,string"` 41 | DiffName string `json:"version"` 42 | FileMD5 string `json:"file_md5"` 43 | CircleSize float64 `json:"diff_size,string"` 44 | OverallDifficulty float64 `json:"diff_overall,string"` 45 | ApproachRate float64 `json:"diff_approach,string"` 46 | HPDrain float64 `json:"diff_drain,string"` 47 | Mode Mode `json:"mode,string"` 48 | ApprovedDate MySQLDate `json:"approved_date"` 49 | LastUpdate MySQLDate `json:"last_update"` 50 | Artist string `json:"artist"` 51 | Title string `json:"title"` 52 | Creator string `json:"creator"` 53 | BPM float64 `json:"bpm,string"` 54 | Source string `json:"source"` 55 | Tags string `json:"tags"` 56 | Genre Genre `json:"genre_id,string"` 57 | Language Language `json:"language_id,string"` 58 | FavouriteCount int `json:"favourite_count,string"` 59 | Playcount int `json:"playcount,string"` 60 | Passcount int `json:"passcount,string"` 61 | MaxCombo int `json:"max_combo,string"` 62 | DifficultyRating float64 `json:"difficultyrating,string"` 63 | Video OsuBool `json:"video"` 64 | } 65 | 66 | // GetBeatmaps makes a get_beatmaps request to the osu! API. 67 | func (c Client) GetBeatmaps(opts GetBeatmapsOpts) ([]Beatmap, error) { 68 | // setup of querystring values 69 | vals := url.Values{} 70 | switch { 71 | case opts.UserID != 0: 72 | vals.Add("u", strconv.Itoa(opts.UserID)) 73 | vals.Add("type", "id") 74 | case opts.Username != "": 75 | vals.Add("u", opts.Username) 76 | vals.Add("type", "string") 77 | } 78 | if opts.Mode != nil { 79 | vals.Add("m", strconv.Itoa(int(*opts.Mode))) 80 | } 81 | if opts.BeatmapHash != "" { 82 | vals.Add("h", opts.BeatmapHash) 83 | } 84 | if opts.BeatmapID != 0 { 85 | vals.Add("b", strconv.Itoa(opts.BeatmapID)) 86 | } 87 | if opts.BeatmapSetID != 0 { 88 | vals.Add("s", strconv.Itoa(opts.BeatmapSetID)) 89 | } 90 | if opts.IncludeConverted { 91 | vals.Add("a", "1") 92 | } 93 | if opts.Since != nil { 94 | vals.Add("since", MySQLDate(*opts.Since).String()) 95 | } 96 | if opts.Limit != 0 { 97 | vals.Add("limit", strconv.Itoa(opts.Limit)) 98 | } 99 | 100 | // actual request 101 | rawData, err := c.makerq("get_beatmaps", vals) 102 | if err != nil { 103 | return nil, err 104 | } 105 | beatmaps := []Beatmap{} 106 | err = json.Unmarshal(rawData, &beatmaps) 107 | if err != nil { 108 | return nil, err 109 | } 110 | return beatmaps, nil 111 | } 112 | -------------------------------------------------------------------------------- /get_match.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "encoding/json" 5 | "net/url" 6 | "strconv" 7 | ) 8 | 9 | // Match is a multiplayer match. 10 | type Match struct { 11 | Info MatchInfo `json:"match"` 12 | Games []MatchGame `json:"games"` 13 | } 14 | 15 | // MatchInfo contains useful information about a Match. 16 | type MatchInfo struct { 17 | MatchID int `json:"match_id,string"` 18 | Name string `json:"name"` 19 | StartTime MySQLDate `json:"start_time"` 20 | EndTime *MySQLDate `json:"end_time"` 21 | } 22 | 23 | // MatchGame is a single beatmap played in the Match. 24 | type MatchGame struct { 25 | GameID int `json:"game_id,string"` 26 | StartTime MySQLDate `json:"start_time"` 27 | EndTime *MySQLDate `json:"end_time"` 28 | BeatmapID int `json:"beatmap_id,string"` 29 | PlayMode Mode `json:"play_mode,string"` 30 | // Refer to the wiki for information about what the three things below 31 | // are. I personally think that this information wouldn't be that 32 | // necessary to most people, and what is written on the wiki could be 33 | // outdated, thus I deemed useless making an appropriate "enum" (like 34 | // I did for Genre, Language and that stuff.) 35 | // You really can say I love writing long comments. 36 | MatchType int `json:"match_type,string"` 37 | ScoringType int `json:"scoring_type,string"` 38 | TeamType int `json:"team_type,string"` 39 | Mods Mods `json:"mods,string"` 40 | Scores []MatchGameScore `json:"scores"` 41 | } 42 | 43 | // MatchGameScore is a single score done by an user in a specific Game of a 44 | // Match. I agree, these descriptions are quite confusing. 45 | type MatchGameScore struct { 46 | Slot int `json:"slot,string"` 47 | Team int `json:"team,string"` 48 | UserID int `json:"user_id,string"` 49 | Score int64 `json:"score,string"` 50 | MaxCombo int `json:"maxcombo,string"` 51 | // There should be Rank here, but Rank is not actually used. (always 0) 52 | Count50 int `json:"count50,string"` 53 | Count100 int `json:"count100,string"` 54 | Count300 int `json:"count300,string"` 55 | CountMiss int `json:"countmiss,string"` 56 | CountGeki int `json:"countgeki,string"` 57 | CountKatu int `json:"countkatu,string"` 58 | // There should also be Perfect here, but that seems to also not be used. (always 0) 59 | Pass OsuBool `json:"pass"` 60 | } 61 | 62 | // GetMatch makes a get_match request to the osu! API. 63 | func (c Client) GetMatch(matchID int) (*Match, error) { 64 | vals := url.Values{ 65 | "mp": []string{strconv.Itoa(matchID)}, 66 | } 67 | rawData, err := c.makerq("get_match", vals) 68 | if err != nil { 69 | return nil, err 70 | } 71 | match := Match{} 72 | err = json.Unmarshal(rawData, &match) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return &match, nil 77 | } 78 | -------------------------------------------------------------------------------- /get_replay.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "net/url" 10 | "strconv" 11 | ) 12 | 13 | // GetReplayOpts are the options that MUST be used to fetch a replay. 14 | // ALL of the fields are **REQUIRED**, with an exception for UserID/Username, 15 | // of which only one is required. 16 | type GetReplayOpts struct { 17 | UserID int 18 | Username string 19 | Mode Mode 20 | BeatmapID int 21 | } 22 | 23 | type replayResponse struct { 24 | Content string `json:"content"` 25 | } 26 | 27 | // GetReplay makes a get_replay request to the osu! API. Returns a reader from 28 | // which the replay can be retrieved. 29 | func (c Client) GetReplay(opts GetReplayOpts) (io.Reader, error) { 30 | vals := url.Values{} 31 | if opts.BeatmapID == 0 { 32 | return nil, errors.New("osuapi: BeatmapID MUST be set in GetReplayOpts") 33 | } 34 | vals.Add("m", strconv.Itoa(int(opts.Mode))) 35 | vals.Add("b", strconv.Itoa(opts.BeatmapID)) 36 | switch { 37 | case opts.UserID != 0: 38 | vals.Add("u", strconv.Itoa(opts.UserID)) 39 | vals.Add("type", "id") 40 | case opts.Username != "": 41 | vals.Add("u", opts.Username) 42 | vals.Add("type", "string") 43 | default: 44 | return nil, errors.New("osuapi: either UserID or Username MUST be set in GetReplayOpts") 45 | } 46 | data, err := c.makerq("get_replay", vals) 47 | if err != nil { 48 | return nil, err 49 | } 50 | rr := replayResponse{} 51 | err = json.Unmarshal(data, &rr) 52 | if err != nil { 53 | return nil, err 54 | } 55 | reader := bytes.NewBuffer([]byte(rr.Content)) 56 | return base64.NewDecoder(base64.StdEncoding, reader), nil 57 | } 58 | -------------------------------------------------------------------------------- /get_scores.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // GetScoresOpts is a struct containing the GET query string parameters to an 11 | // /api/get_scores request. 12 | type GetScoresOpts struct { 13 | BeatmapID int 14 | // As usual, if both UserID and Username are set, UserID will override Username. 15 | UserID int 16 | Username string 17 | Mode Mode 18 | Mods *Mods // Pointer because must have the possibility to be 0 (nomod) but also nil (whatever is fine) 19 | Limit int 20 | } 21 | 22 | // Score is an osu! score. Used in both get_scores, get_user_best and get_user_recent. 23 | type Score struct { 24 | Score int64 `json:"score,string"` 25 | MaxCombo int `json:"maxcombo,string"` 26 | Count50 int `json:"count50,string"` 27 | Count100 int `json:"count100,string"` 28 | Count300 int `json:"count300,string"` 29 | CountMiss int `json:"countmiss,string"` 30 | CountKatu int `json:"countkatu,string"` 31 | CountGeki int `json:"countgeki,string"` 32 | FullCombo OsuBool `json:"perfect,string"` 33 | Mods Mods `json:"enabled_mods,string"` 34 | UserID int `json:"user_id,string"` 35 | Date MySQLDate `json:"date"` 36 | Rank string `json:"rank"` // Rank = SSH, SS, SH, S, A, B, C, D 37 | PP float64 `json:"pp,string"` 38 | } 39 | 40 | // GSScore is basically Score, with the exception it also has ScoreID. 41 | // (stands for Get Scores Score) 42 | type GSScore struct { 43 | ScoreID int64 `json:"score_id,string"` 44 | Username string `json:"username"` 45 | Score 46 | } 47 | 48 | // GetScores makes a get_scores request to the osu! API. 49 | func (c Client) GetScores(opts GetScoresOpts) ([]GSScore, error) { 50 | // setup of querystring values 51 | vals := url.Values{} 52 | if opts.BeatmapID == 0 { 53 | return nil, errors.New("osuapi: BeatmapID must be set in GetScoresOpts") 54 | } 55 | vals.Add("b", strconv.Itoa(opts.BeatmapID)) 56 | switch { 57 | case opts.UserID != 0: 58 | vals.Add("u", strconv.Itoa(opts.UserID)) 59 | vals.Add("type", "id") 60 | case opts.Username != "": 61 | vals.Add("u", opts.Username) 62 | vals.Add("type", "string") 63 | } 64 | vals.Add("m", strconv.Itoa(int(opts.Mode))) 65 | if opts.Mods != nil { 66 | vals.Add("mods", strconv.Itoa(int(*opts.Mods))) 67 | } 68 | if opts.Limit != 0 { 69 | vals.Add("limit", strconv.Itoa(opts.Limit)) 70 | } 71 | 72 | // actual request 73 | rawData, err := c.makerq("get_scores", vals) 74 | if err != nil { 75 | return nil, err 76 | } 77 | scores := []GSScore{} 78 | err = json.Unmarshal(rawData, &scores) 79 | if err != nil { 80 | return nil, err 81 | } 82 | return scores, nil 83 | } 84 | -------------------------------------------------------------------------------- /get_user.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // ErrNoSuchUser is returned when the requested user could not be found. 11 | var ErrNoSuchUser = errors.New("osuapi: no such user could be found") 12 | 13 | // GetUserOpts is a struct containing the GET query string parameters to an 14 | // /api/get_user request. 15 | type GetUserOpts struct { 16 | // If both UserID and Username are set, UserID will be used. 17 | UserID int 18 | Username string 19 | Mode Mode 20 | EventDays int 21 | } 22 | 23 | // User is an osu! user. 24 | type User struct { 25 | UserID int `json:"user_id,string"` 26 | Username string `json:"username"` 27 | Date MySQLDate `json:"join_date"` 28 | Count300 int `json:"count300,string"` 29 | Count100 int `json:"count100,string"` 30 | Count50 int `json:"count50,string"` 31 | Playcount int `json:"playcount,string"` 32 | RankedScore int64 `json:"ranked_score,string"` 33 | TotalScore int64 `json:"total_score,string"` 34 | Rank int `json:"pp_rank,string"` 35 | Level float64 `json:"level,string"` 36 | PP float64 `json:"pp_raw,string"` 37 | Accuracy float64 `json:"accuracy,string"` 38 | CountSS int `json:"count_rank_ss,string"` 39 | CountSSH int `json:"count_rank_ssh,string"` 40 | CountS int `json:"count_rank_s,string"` 41 | CountSH int `json:"count_rank_sh,string"` 42 | CountA int `json:"count_rank_a,string"` 43 | Country string `json:"country"` 44 | CountryRank int `json:"pp_country_rank,string"` 45 | Events []Event `json:"events"` 46 | } 47 | 48 | // Event is a notorious action an user has done recently. 49 | type Event struct { 50 | DisplayHTML string `json:"display_html"` 51 | BeatmapID int `json:"beatmap_id,string"` 52 | BeatmapsetID int `json:"beatmapset_id,string"` 53 | Date MySQLDate `json:"date"` 54 | Epicfactor int `json:"epicfactor,string"` 55 | } 56 | 57 | // GetUser makes a get_user request to the osu! API. 58 | func (c Client) GetUser(opts GetUserOpts) (*User, error) { 59 | // setup of querystring values 60 | vals := url.Values{} 61 | switch { 62 | case opts.UserID != 0: 63 | vals.Add("u", strconv.Itoa(opts.UserID)) 64 | vals.Add("type", "id") 65 | case opts.Username != "": 66 | vals.Add("u", opts.Username) 67 | vals.Add("type", "string") 68 | default: 69 | return nil, errors.New("osuapi: either UserID or Username must be set in GetUserOpts") 70 | } 71 | vals.Add("m", strconv.Itoa(int(opts.Mode))) 72 | if opts.EventDays != 0 { 73 | vals.Add("event_days", strconv.Itoa(opts.EventDays)) 74 | } 75 | 76 | // actual request 77 | rawData, err := c.makerq("get_user", vals) 78 | if err != nil { 79 | return nil, err 80 | } 81 | users := []User{} 82 | err = json.Unmarshal(rawData, &users) 83 | if err != nil { 84 | return nil, err 85 | } 86 | if len(users) == 0 { 87 | return nil, ErrNoSuchUser 88 | } 89 | return &users[0], nil 90 | } 91 | 92 | // ToGetUserOpts converts an user to a GetUserOpts, so that it can be used 93 | // with GetUser. Note that this does not work very well. It won't auto-detect 94 | // the game mode, because the bloody osu! API does not return that in a 95 | // get_user response. So it will just assume you want the osu! standard data 96 | // and return that. 97 | func (u User) ToGetUserOpts() GetUserOpts { 98 | return GetUserOpts{ 99 | UserID: u.UserID, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /get_user_best.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // GetUserScoresOpts are the options that can be passed in GetUserBest or 11 | // in GetUserRecent; they use the same parameters. 12 | type GetUserScoresOpts struct { 13 | // You know it by now. UserID overrides Username. 14 | UserID int 15 | Username string 16 | Mode Mode 17 | Limit int 18 | } 19 | 20 | // GUSScore is a score from get_user_best or get_user_recent. It differs from 21 | // normal Score by having a BeatmapID field. Stands for Get User Scores Score. 22 | // Yeah, I suck at choosing names. but that's what programming is all about, 23 | // after all. 24 | type GUSScore struct { 25 | BeatmapID int `json:"beatmap_id,string"` 26 | Score 27 | } 28 | 29 | func (o GetUserScoresOpts) toValues() url.Values { 30 | vals := url.Values{} 31 | switch { 32 | case o.UserID != 0: 33 | vals.Add("u", strconv.Itoa(o.UserID)) 34 | vals.Add("type", "id") 35 | case o.Username != "": 36 | vals.Add("u", o.Username) 37 | vals.Add("type", "string") 38 | } 39 | vals.Add("m", strconv.Itoa(int(o.Mode))) 40 | if o.Limit != 0 { 41 | vals.Add("limit", strconv.Itoa(o.Limit)) 42 | } 43 | return vals 44 | } 45 | 46 | // GetUserBest makes a get_user_best request to the osu! API. 47 | func (c Client) GetUserBest(opts GetUserScoresOpts) ([]GUSScore, error) { 48 | if opts.UserID == 0 && opts.Username == "" { 49 | return nil, errors.New("osuapi: must have either UserID or Username in GetUserScoresOpts") 50 | } 51 | 52 | rawData, err := c.makerq("get_user_best", opts.toValues()) 53 | if err != nil { 54 | return nil, err 55 | } 56 | scores := []GUSScore{} 57 | err = json.Unmarshal(rawData, &scores) 58 | if err != nil { 59 | return nil, err 60 | } 61 | return scores, nil 62 | } 63 | -------------------------------------------------------------------------------- /get_user_recent.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | ) 7 | 8 | // GetUserRecent makes a get_user_recent request to the osu! API. 9 | func (c Client) GetUserRecent(opts GetUserScoresOpts) ([]GUSScore, error) { 10 | if opts.UserID == 0 && opts.Username == "" { 11 | return nil, errors.New("osuapi: must have either UserID or Username in GetUserScoresOpts") 12 | } 13 | 14 | rawData, err := c.makerq("get_user_recent", opts.toValues()) 15 | if err != nil { 16 | return nil, err 17 | } 18 | scores := []GUSScore{} 19 | err = json.Unmarshal(rawData, &scores) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return scores, nil 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thehowl/go-osuapi 2 | 3 | go 1.21.3 4 | -------------------------------------------------------------------------------- /language.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import "strconv" 4 | 5 | // Languages 6 | const ( 7 | LanguageAny Language = iota 8 | LanguageOther 9 | LanguageEnglish 10 | LanguageJapanese 11 | LanguageChinese 12 | LanguageInstrumental 13 | LanguageKorean 14 | LanguageFrench 15 | LanguageGerman 16 | LanguageSwedish 17 | LanguageSpanish 18 | LanguageItalian 19 | ) 20 | 21 | // Language is the language of a beatmap's song. 22 | type Language int 23 | 24 | var languageString = [...]string{ 25 | "any", 26 | "other", 27 | "English", 28 | "Japanese", 29 | "Chinese", 30 | "instrumental", 31 | "Korean", 32 | "French", 33 | "German", 34 | "Swedish", 35 | "Spanish", 36 | "Italian", 37 | } 38 | 39 | func (l Language) String() string { 40 | if l >= 0 && int(l) < len(languageString) { 41 | return languageString[l] 42 | } 43 | return strconv.Itoa(int(l)) 44 | } 45 | -------------------------------------------------------------------------------- /mode.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import "strconv" 4 | 5 | // osu! game modes IDs. 6 | const ( 7 | ModeOsu Mode = iota 8 | ModeTaiko 9 | ModeCatchTheBeat 10 | ModeOsuMania 11 | ) 12 | 13 | // Mode is an osu! game mode. 14 | type Mode int 15 | 16 | var modesString = [...]string{ 17 | "osu!", 18 | "Taiko", 19 | "Catch the Beat", 20 | "osu!mania", 21 | } 22 | 23 | func (m Mode) String() string { 24 | if m >= 0 && m <= 3 { 25 | return modesString[m] 26 | } 27 | return strconv.Itoa(int(m)) 28 | } 29 | -------------------------------------------------------------------------------- /mods.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | // Mods in the game. 4 | const ( 5 | ModNoFail Mods = 1 << iota 6 | ModEasy 7 | ModNoVideo 8 | ModHidden 9 | ModHardRock 10 | ModSuddenDeath 11 | ModDoubleTime 12 | ModRelax 13 | ModHalfTime 14 | ModNightcore 15 | ModFlashlight 16 | ModAutoplay 17 | ModSpunOut 18 | ModRelax2 19 | ModPerfect 20 | ModKey4 21 | ModKey5 22 | ModKey6 23 | ModKey7 24 | ModKey8 25 | ModFadeIn 26 | ModRandom 27 | ModLastMod 28 | ModKey9 29 | ModKey10 30 | ModKey1 31 | ModKey3 32 | ModKey2 33 | ModFreeModAllowed = ModNoFail | ModEasy | ModHidden | ModHardRock | ModSuddenDeath | ModFlashlight | ModFadeIn | ModRelax | ModRelax2 | ModSpunOut | ModKeyMod 34 | ModKeyMod = ModKey4 | ModKey5 | ModKey6 | ModKey7 | ModKey8 35 | ) 36 | 37 | // Mods is a bitwise enum of mods used in a score. 38 | // 39 | // Mods may appear complicated to use for a beginner programmer. Fear not! 40 | // This is how hard they can get for creation of a mod combination: 41 | // 42 | // myModCombination := osuapi.ModHardRock | osuapi.ModDoubleTime | osuapi.ModHidden | osuapi.ModSpunOut 43 | // 44 | // As for checking that an existing mod comination is enabled: 45 | // 46 | // if modCombination&osuapi.ModHardRock != 0 { 47 | // // HardRock is enabled 48 | // } 49 | // 50 | // To learn more about bitwise operators, have a look at it on wikipedia: 51 | // https://en.wikipedia.org/wiki/Bitwise_operation#Bitwise_operators 52 | type Mods int 53 | 54 | var modsString = [...]string{ 55 | "NF", 56 | "EZ", 57 | "NV", 58 | "HD", 59 | "HR", 60 | "SD", 61 | "DT", 62 | "RX", 63 | "HT", 64 | "NC", 65 | "FL", 66 | "AU", // Auto. 67 | "SO", 68 | "AP", // Autopilot. 69 | "PF", 70 | "K4", 71 | "K5", 72 | "K6", 73 | "K7", 74 | "K8", 75 | "K9", 76 | "RN", // Random 77 | "LM", // LastMod. Cinema? 78 | "K9", 79 | "K0", 80 | "K1", 81 | "K3", 82 | "K2", 83 | } 84 | 85 | // ParseMods parse a string with mods in the format "HDHRDT" 86 | func ParseMods(mods string) (m Mods) { 87 | modsSl := make([]string, len(mods)/2) 88 | for n, modPart := range mods { 89 | modsSl[n/2] += string(modPart) 90 | } 91 | for _, mod := range modsSl { 92 | for index, availableMod := range modsString { 93 | if availableMod == mod { 94 | m |= 1 << uint(index) 95 | break 96 | } 97 | } 98 | } 99 | return 100 | } 101 | 102 | func (m Mods) String() (s string) { 103 | for i := 0; i < len(modsString); i++ { 104 | activated := 1&m == 1 105 | if activated { 106 | s += modsString[i] 107 | } 108 | m >>= 1 109 | } 110 | return 111 | } 112 | -------------------------------------------------------------------------------- /mods_test.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParseMods(t *testing.T) { 8 | if ParseMods("HDHRDT").String() != "HDHRDT" { 9 | t.Fatal("Expected Mods.String() to return HDHRDT, returned", ParseMods("HDHRDT").String()) 10 | } 11 | } 12 | 13 | func TestParseModsShouldIgnore(t *testing.T) { 14 | mods := ParseMods("EZHDHRPFMEMETIgnore me I'm useless") 15 | if mods.String() != "EZHDHRPF" { 16 | t.Fatal("Expected HDEZHRPF, got", mods.String()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /mysql_date.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // MySQLDate is a wrapper for time.Time that can get a date from an osu! API JSON response. 8 | type MySQLDate time.Time 9 | 10 | // UnmarshalJSON takes some JSON data and does some magic to transform it into a native time.Time. 11 | func (m *MySQLDate) UnmarshalJSON(data []byte) error { 12 | dataString := string(data) 13 | if dataString == "null" { 14 | m = nil 15 | return nil 16 | } 17 | inTimeLib, err := time.Parse(`"2006-01-02 15:04:05"`, dataString) 18 | if err != nil { 19 | return err 20 | } 21 | *m = MySQLDate(inTimeLib) 22 | return nil 23 | } 24 | 25 | // MarshalJSON converts a MySQLDate into JSON. 26 | func (m MySQLDate) MarshalJSON() ([]byte, error) { 27 | return []byte("\"" + m.String() + "\""), nil 28 | } 29 | 30 | // GetTime transforms a MySQLDate into a native time.Time. 31 | func (m MySQLDate) GetTime() time.Time { 32 | return time.Time(m) 33 | } 34 | 35 | func (m MySQLDate) String() string { 36 | return m.GetTime().Format("2006-01-02 15:04:05") 37 | } 38 | -------------------------------------------------------------------------------- /osubool.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | // OsuBool is just a bool. It's used for unmarshaling of bools in the API that 4 | // are either `"1"` or `"0"`. thank mr peppy for the memes 5 | // 6 | // You can just use it in `if`s and other memes. Should you need to convert it 7 | // to a native bool, just do `bool(yourOsuBool)` 8 | type OsuBool bool 9 | 10 | // UnmarshalJSON converts `"1"` and `1` to true and all other values to false. 11 | func (o *OsuBool) UnmarshalJSON(data []byte) error { 12 | dataString := string(data) 13 | if dataString == `1` || dataString == `"1"` { 14 | *o = true 15 | return nil 16 | } 17 | *o = false 18 | return nil 19 | } 20 | 21 | // MarshalJSON does UnmarshalJSON the other way around. 22 | func (o OsuBool) MarshalJSON() ([]byte, error) { 23 | if o { 24 | return []byte(`"1"`), nil 25 | } 26 | return []byte(`"0"`), nil 27 | } 28 | -------------------------------------------------------------------------------- /rate_limit.go: -------------------------------------------------------------------------------- 1 | package osuapi 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | var every time.Duration 8 | var requestsAvailable chan struct{} 9 | var routStarted bool 10 | 11 | // RateLimit allows you to set the maximum number of requests to do in a 12 | // minute to the osu! API. 13 | // 14 | // Please note that this function is NOT thread safe. It should be executed 15 | // only at the start of your program, and never after it. 16 | // 17 | // The reason for this is that creating a Mutex for a channel is just 18 | // absolutely ridiculous. 19 | func RateLimit(maxRequests int) { 20 | if maxRequests == 0 { 21 | requestsAvailable = nil 22 | } 23 | every = 60000 * time.Millisecond / time.Duration(maxRequests) 24 | requestsAvailable = make(chan struct{}, maxRequests) 25 | for { 26 | var b bool 27 | select { 28 | case requestsAvailable <- struct{}{}: 29 | // nothing, just keep on moving 30 | default: 31 | b = true 32 | } 33 | if b { 34 | break 35 | } 36 | } 37 | if !routStarted { 38 | go requestIncreaser() 39 | } 40 | routStarted = true 41 | } 42 | func requestIncreaser() { 43 | for { 44 | time.Sleep(every) 45 | requestsAvailable <- struct{}{} 46 | } 47 | } 48 | --------------------------------------------------------------------------------