diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000000000000000000000000000000000000..dbc57c0fce44031c43204103b2520f8f64a49794 --- /dev/null +++ b/api/api.go @@ -0,0 +1,89 @@ +// Package api contains the general framework for writing handlers in the +// CheeseGull API. +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + + "github.com/julienschmidt/httprouter" +) + +// Context is the information that is passed to all request handlers in relation +// to the request, and how to answer it. +type Context struct { + Request *http.Request + DB *sql.DB + writer http.ResponseWriter + params httprouter.Params +} + +// Write writes content to the response body. +func (c *Context) Write(b []byte) (int, error) { + return c.writer.Write(b) +} + +// ReadHeader reads a header from the request. +func (c *Context) ReadHeader(s string) string { + return c.Request.Header.Get(s) +} + +// WriteHeader sets a header in the response. +func (c *Context) WriteHeader(key, value string) { + c.writer.Header().Set(key, value) +} + +// Code sets the response's code. +func (c *Context) Code(i int) { + c.writer.WriteHeader(i) +} + +// Param retrieves a parameter in the URL's path. +func (c *Context) Param(s string) string { + return c.params.ByName(s) +} + +// WriteJSON writes JSON to the response. +func (c *Context) WriteJSON(code int, v interface{}) error { + c.WriteHeader("Content-Type", "application/json; charset=utf-8") + c.Code(code) + return json.NewEncoder(c.writer).Encode(v) +} + +type handlerPath struct { + method, path string + f func(c *Context) +} + +var handlers []handlerPath + +// GET registers a handler for a GET request. +func GET(path string, f func(c *Context)) { + handlers = append(handlers, handlerPath{"GET", path, f}) +} + +// POST registers a handler for a POST request. +func POST(path string, f func(c *Context)) { + handlers = append(handlers, handlerPath{"POST", path, f}) +} + +// CreateHandler creates a new http.Handler using the handlers registered +// through GET and POST. +func CreateHandler(db *sql.DB) http.Handler { + r := httprouter.New() + for _, h := range handlers { + // Create local copy that we know won't change as the loop proceeds. + h := h + r.Handle(h.method, h.path, func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ctx := &Context{ + Request: r, + DB: db, + writer: w, + params: p, + } + h.f(ctx) + }) + } + return r +} diff --git a/api/index.go b/api/index.go new file mode 100644 index 0000000000000000000000000000000000000000..f1be650ec9d4d2932cf99b89be0940c136fcc1cc --- /dev/null +++ b/api/index.go @@ -0,0 +1,10 @@ +package api + +func index(c *Context) { + c.WriteHeader("Content-Type", "text/plain; charset=utf-8") + c.Write([]byte("CheeseGull v2.x Woo\nFor more information: https://github.com/osuripple/cheesegull")) +} + +func init() { + GET("/", index) +} diff --git a/api/metadata/single.go b/api/metadata/single.go new file mode 100644 index 0000000000000000000000000000000000000000..a4da7aa81258d3b007ac073636d406c0d37fcfea --- /dev/null +++ b/api/metadata/single.go @@ -0,0 +1,63 @@ +// Package metadata handles API request that search for metadata regarding osu! +// beatmaps. +package metadata + +import ( + "fmt" + "strconv" + "strings" + + "github.com/osuripple/cheesegull/api" + "github.com/osuripple/cheesegull/models" +) + +// Beatmap handles requests to retrieve single beatmaps. +func Beatmap(c *api.Context) { + id, _ := strconv.Atoi(strings.TrimSuffix(c.Param("id"), ".json")) + if id == 0 { + c.WriteJSON(404, nil) + return + } + + bms, err := models.FetchBeatmaps(c.DB, id) + if err != nil { + fmt.Println("Error fetching beatmap", err) + c.WriteJSON(500, nil) + return + } + if len(bms) == 0 { + c.WriteJSON(404, nil) + return + } + + c.WriteJSON(200, bms[0]) +} + +// Set handles requests to retrieve single beatmap sets. +func Set(c *api.Context) { + id, _ := strconv.Atoi(strings.TrimSuffix(c.Param("id"), ".json")) + if id == 0 { + c.WriteJSON(404, nil) + return + } + + set, err := models.FetchSet(c.DB, id) + if err != nil { + fmt.Println("Error fetching beatmap", err) + c.WriteJSON(500, nil) + return + } + if set == nil { + c.WriteJSON(404, nil) + return + } + + c.WriteJSON(200, set) +} + +func init() { + api.GET("/api/b/:id", Beatmap) + api.GET("/b/:id", Beatmap) + api.GET("/api/s/:id", Set) + api.GET("/s/:id", Set) +} diff --git a/cheesegull.go b/cheesegull.go index 82add19daa3fc8163964371afa2cc60d1e507829..62c8bd5eea504f7f4269f5541cbd87cca5fc80b5 100644 --- a/cheesegull.go +++ b/cheesegull.go @@ -3,16 +3,22 @@ package main import ( "database/sql" "fmt" + "net/http" "os" "strings" "time" "github.com/alecthomas/kingpin" _ "github.com/go-sql-driver/mysql" + osuapi "github.com/thehowl/go-osuapi" + + "github.com/osuripple/cheesegull/api" "github.com/osuripple/cheesegull/dbmirror" "github.com/osuripple/cheesegull/downloader" "github.com/osuripple/cheesegull/models" - osuapi "github.com/thehowl/go-osuapi" + + // Components of the API we want to use + _ "github.com/osuripple/cheesegull/api/metadata" ) var ( @@ -20,6 +26,7 @@ var ( osuUsername = kingpin.Flag("osu-username", "osu! username (for downloading and fetching whether a beatmap has a video)").Short('u').Envar("OSU_USERNAME").String() osuPassword = kingpin.Flag("osu-password", "osu! password (for downloading and fetching whether a beatmap has a video)").Short('p').Envar("OSU_PASSWORD").String() mysqlDSN = kingpin.Flag("mysql-dsn", "DSN of MySQL").Short('m').Default("root@/cheesegull").Envar("MYSQL_DSN").String() + httpAddr = kingpin.Flag("http-addr", "Address on which to take HTTP requests.").Short('a').Default("127.0.0.1:62011").String() ) func addTimeParsing(dsn string) string { @@ -62,5 +69,8 @@ func main() { // start running components of cheesegull go dbmirror.StartSetUpdater(c, db) - dbmirror.DiscoverEvery(c, db, time.Minute*30, time.Second*20) + go dbmirror.DiscoverEvery(c, db, time.Minute*30, time.Second*20) + + // create request handler + panic(http.ListenAndServe(*httpAddr, api.CreateHandler(db))) } diff --git a/models/beatmap.go b/models/beatmap.go index aa9e39aa92d8fc386b2efa6e23f907267a576fc0..925be33a7a7dddcaed1bd44f0ec59e4ed9474d15 100644 --- a/models/beatmap.go +++ b/models/beatmap.go @@ -22,21 +22,64 @@ type Beatmap struct { DifficultyRating float64 } +const beatmapFields = ` +id, parent_set_id, diff_name, file_md5, mode, bpm, +ar, od, cs, hp, total_length, hit_length, +playcount, passcount, max_combo, difficulty_rating` + +func readBeatmapsFromRows(rows *sql.Rows, capacity int) ([]Beatmap, error) { + var err error + bms := make([]Beatmap, 0, capacity) + for rows.Next() { + var b Beatmap + err = rows.Scan( + &b.ID, &b.ParentSetID, &b.DiffName, &b.FileMD5, &b.Mode, &b.BPM, + &b.AR, &b.OD, &b.CS, &b.HP, &b.TotalLength, &b.HitLength, + &b.Playcount, &b.Passcount, &b.MaxCombo, &b.DifficultyRating, + ) + if err != nil { + return nil, err + } + bms = append(bms, b) + } + + return bms, rows.Err() +} + +// FetchBeatmaps retrieves a list of beatmap knowing their IDs. +func FetchBeatmaps(db *sql.DB, ids ...int) ([]Beatmap, error) { + if len(ids) == 0 { + return nil, nil + } + + q := `SELECT ` + beatmapFields + ` FROM beatmaps WHERE id IN (` + args := make([]interface{}, len(ids)) + + for idx, id := range ids { + if idx != 0 { + q += ", " + } + q += "?" + args[idx] = id + } + + rows, err := db.Query(q+");", args...) + if err != nil { + return nil, err + } + + return readBeatmapsFromRows(rows, len(ids)) +} + // CreateBeatmaps adds beatmaps in the database. func CreateBeatmaps(db *sql.DB, bms ...Beatmap) error { if len(bms) == 0 { return nil } - q := ` -INSERT INTO beatmaps( - id, parent_set_id, diff_name, mode, bpm, - ar, od, cs, hp, total_length, hit_length, - playcount, passcount, max_combo, difficulty_rating -) -VALUES ` + q := `INSERT INTO beatmaps(` + beatmapFields + `) VALUES ` const valuePlaceholder = `( - ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )` @@ -48,7 +91,7 @@ VALUES ` } q += valuePlaceholder args = append(args, - bm.ID, bm.ParentSetID, bm.DiffName, bm.Mode, bm.BPM, + bm.ID, bm.ParentSetID, bm.DiffName, bm.FileMD5, bm.Mode, bm.BPM, bm.AR, bm.OD, bm.CS, bm.HP, bm.TotalLength, bm.HitLength, bm.Playcount, bm.Passcount, bm.MaxCombo, bm.DifficultyRating, ) diff --git a/models/migrations.go b/models/migrations.go index 228567e67955c6584622ff54307088274a6cd40c..60451fc09feaf33d9573202f65ac487f410b08cd 100644 --- a/models/migrations.go +++ b/models/migrations.go @@ -27,6 +27,7 @@ var migrations = [...]string{ id INT NOT NULL, parent_set_id INT NOT NULL, diff_name VARCHAR(1000) NOT NULL, + file_md5 CHAR(32) NOT NULL, mode INT NOT NULL, bpm DECIMAL(10, 4) NOT NULL, ar DECIMAL(4, 2) NOT NULL, diff --git a/models/migrations/0002.sql b/models/migrations/0002.sql index 57c3722a9e15231146c6cc57e5acf77d1df27ddf..8744b44c42dcadaf887699bdc4bc810bfdf11934 100644 --- a/models/migrations/0002.sql +++ b/models/migrations/0002.sql @@ -2,6 +2,7 @@ CREATE TABLE beatmaps( id INT NOT NULL, parent_set_id INT NOT NULL, diff_name VARCHAR(1000) NOT NULL, + file_md5 CHAR(32) NOT NULL, mode INT NOT NULL, bpm DECIMAL(10, 4) NOT NULL, ar DECIMAL(4, 2) NOT NULL, diff --git a/models/set.go b/models/set.go index 0d4522d950a26b8f4634c1bac2f380373b8f99a7..a298a7d6f40993f6a650596d7609e566631ff246 100644 --- a/models/set.go +++ b/models/set.go @@ -25,6 +25,10 @@ type Set struct { Favourites int } +const setFields = `id, ranked_status, approved_date, last_update, last_checked, +artist, title, creator, source, tags, has_video, genre, +language, favourites` + // FetchSetsForBatchUpdate fetches limit sets from the database, sorted by // LastChecked (asc, older first). Results are further filtered: if the set's // RankedStatus is 3, 0 or -1 (qualified, pending or WIP), at least 30 minutes @@ -33,11 +37,7 @@ type Set struct { func FetchSetsForBatchUpdate(db *sql.DB, limit int) ([]Set, error) { n := time.Now() rows, err := db.Query(` -SELECT - id, ranked_status, approved_date, last_update, last_checked, - artist, title, creator, source, tags, has_video, genre, - language, favourites -FROM sets +SELECT `+setFields+` FROM sets WHERE (ranked_status IN (3, 0, -1) AND last_checked <= ?) OR last_checked <= ? ORDER BY last_checked ASC LIMIT ?`, @@ -63,7 +63,33 @@ LIMIT ?`, sets = append(sets, s) } - return sets, nil + return sets, rows.Err() +} + +// FetchSet retrieves a single set to show, alongside its children beatmaps. +func FetchSet(db *sql.DB, id int) (*Set, error) { + var s Set + err := db.QueryRow(`SELECT `+setFields+` FROM sets WHERE id = ? LIMIT 1`, id).Scan( + &s.ID, &s.RankedStatus, &s.ApprovedDate, &s.LastUpdate, &s.LastChecked, + &s.Artist, &s.Title, &s.Creator, &s.Source, &s.Tags, &s.HasVideo, &s.Genre, + &s.Language, &s.Favourites, + ) + switch err { + case nil: + break // carry on + case sql.ErrNoRows: + // silently ignore no rows, and just don't return anything + return nil, nil + default: + return nil, err + } + + rows, err := db.Query(`SELECT `+beatmapFields+` FROM beatmaps WHERE parent_set_id = ?`, s.ID) + if err != nil { + return nil, err + } + s.ChildrenBeatmaps, err = readBeatmapsFromRows(rows, 8) + return &s, err } // DeleteSet deletes a set from the database, removing also its children