From eded415df41b8a5f14815369d9e0dbc6d1b4a709 Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Thu, 23 Jul 2026 22:48:12 -0400 Subject: [PATCH] cmd/toradl: program for downloading icons from gametora --- cmd/toradl/README.md | 8 +++ cmd/toradl/main.go | 156 +++++++++++++++++++++++++++++++++++++++++++ cmd/toradl/mdb.go | 51 ++++++++++++++ go.mod | 5 ++ go.sum | 12 +++- 5 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 cmd/toradl/README.md create mode 100644 cmd/toradl/main.go create mode 100644 cmd/toradl/mdb.go diff --git a/cmd/toradl/README.md b/cmd/toradl/README.md new file mode 100644 index 0000000..dc3d240 --- /dev/null +++ b/cmd/toradl/README.md @@ -0,0 +1,8 @@ +# toradl + +Download and process icons from GameTora. +Obtain permission from Gertas before using this program. + +toradl pulls the list of all skill icon IDs, character IDs, and rating IDs from the MDB +Then, for each that has no previous output, it downloads the corresponding icon from GameTora. +Each type is resized to various sizes and saved as PNG and AVIF. diff --git a/cmd/toradl/main.go b/cmd/toradl/main.go new file mode 100644 index 0000000..fa02a31 --- /dev/null +++ b/cmd/toradl/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "image" + "image/png" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "time" + + "github.com/gen2brain/avif" + "golang.org/x/image/draw" + "golang.org/x/sync/errgroup" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +func main() { + var ( + mdb string + out string + ) + flag.StringVar(&mdb, "mdb", "", "`path` to master.mdb") + flag.StringVar(&out, "o", "", "output `dir`ectory") + flag.Parse() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + go func() { + <-ctx.Done() + stop() + }() + + db, err := sqlitex.NewPool(mdb, sqlitex.PoolOptions{Flags: sqlite.OpenReadOnly}) + if err != nil { + slog.Error("mdb", slog.Any("err", err)) + os.Exit(1) + } + if err := os.MkdirAll(out, 0777); err != nil { + slog.Error("output", slog.Any("err", err)) + os.Exit(1) + } + + skill, err := skillIconIDs(ctx, db) + if err != nil { + slog.Error("skills", slog.Any("err", err)) + } + chara, err := charaIDs(ctx, db) + if err != nil { + slog.Error("characters", slog.Any("err", err)) + } + rank, err := rankIDs(ctx, db) + if err != nil { + slog.Error("ranks", slog.Any("err", err)) + } + + g, ctx := errgroup.WithContext(ctx) + tick := time.NewTicker(250 * time.Millisecond) + idouts(ctx, g, tick, skill, filepath.Join(out, "skill"), "https://media.gametora.com/umamusume/skills/icon/%d.png", image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16)) + idouts(ctx, g, tick, chara, filepath.Join(out, "chara"), "https://gametora.com/images/umamusume/characters/icons/chr_icon_%d.png", image.Pt(256, 256), image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16)) + idouts(ctx, g, tick, rank, filepath.Join(out, "rank"), "https://media.gametora.com/umamusume/ui/rank/%02d.png", image.Pt(128, 128), image.Pt(64, 64), image.Pt(32, 32), image.Pt(16, 16)) + idouts(ctx, g, tick, rank, filepath.Join(out, "rank", "simple"), "https://media.gametora.com/umamusume/ui/rank/simple/%02d.png", image.Pt(50, 50), image.Pt(32, 32), image.Pt(16, 16)) + if err := g.Wait(); err != nil { + slog.Error("output", slog.Any("err", err)) + } +} + +var pngenc = &png.Encoder{CompressionLevel: png.BestCompression} + +func idouts(ctx context.Context, g *errgroup.Group, tick *time.Ticker, ids []int32, p, urlf string, sizes ...image.Point) { + for _, id := range ids { + g.Go(func() error { + n := strconv.Itoa(int(id)) + p := filepath.Join(p, n) + _, err := os.Stat(p) + if !errors.Is(err, os.ErrNotExist) { + slog.DebugContext(ctx, "exists", slog.String("path", p)) + return nil + } + if err := os.MkdirAll(p, 0777); err != nil { + return err + } + url := fmt.Sprintf(urlf, id) + select { + case <-ctx.Done(): + return ctx.Err() + case <-tick.C: // continue on + } + img, err := dlimg(ctx, url) + if err != nil { + return err + } + slog.InfoContext(ctx, "resize", slog.String("url", url)) + rs := resize(img, sizes...) + for _, r := range rs { + g.Go(func() error { + m := r.Bounds().Max + o := filepath.Join(p, fmt.Sprintf("%dx%d.png", m.X, m.Y)) + slog.InfoContext(ctx, "write", slog.String("path", o)) + f, err := os.Create(o) + if err != nil { + return err + } + defer f.Close() + return pngenc.Encode(f, r) + }) + g.Go(func() error { + m := r.Bounds().Max + o := filepath.Join(p, fmt.Sprintf("%dx%d.avif", m.X, m.Y)) + slog.InfoContext(ctx, "write", slog.String("path", o)) + f, err := os.Create(o) + if err != nil { + return err + } + defer f.Close() + return avif.Encode(f, r) + }) + } + return nil + }) + } +} + +func dlimg(ctx context.Context, url string) (image.Image, error) { + slog.InfoContext(ctx, "download", slog.String("url", url)) + ctx, stop := context.WithTimeout(ctx, 5*time.Second) + defer stop() + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + slog.InfoContext(ctx, "decode", slog.String("url", url)) + img, _, err := image.Decode(resp.Body) + return img, err +} + +func resize(img image.Image, sizes ...image.Point) []image.Image { + rs := make([]image.Image, 0, len(sizes)) + for _, sz := range sizes { + d := image.NewNRGBA(image.Rectangle{Min: image.Point{}, Max: sz}) + draw.CatmullRom.Scale(d, d.Bounds(), img, img.Bounds(), draw.Src, nil) + rs = append(rs, d) + } + return rs +} diff --git a/cmd/toradl/mdb.go b/cmd/toradl/mdb.go new file mode 100644 index 0000000..543a796 --- /dev/null +++ b/cmd/toradl/mdb.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +// load scans all results of sql and appends them to r. +func load[T any](ctx context.Context, db *sqlitex.Pool, r []T, sql string, row func(*sqlite.Stmt) T) ([]T, error) { + conn, err := db.Take(ctx) + defer db.Put(conn) + if err != nil { + return nil, err + } + stmt, err := conn.Prepare(sql) + if err != nil { + return nil, err + } + + for { + ok, err := stmt.Step() + if err != nil { + return r, err + } + if !ok { + break + } + r = append(r, row(stmt)) + } + return r, err +} + +func skillIconIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) { + return load(ctx, db, nil, "SELECT DISTINCT icon_id FROM skill_data", func(s *sqlite.Stmt) int32 { + return s.ColumnInt32(0) + }) +} + +func charaIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) { + return load(ctx, db, nil, "SELECT id FROM chara_data", func(s *sqlite.Stmt) int32 { + return s.ColumnInt32(0) + }) +} + +func rankIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) { + return load(ctx, db, nil, "SELECT id-1 FROM single_mode_rank", func(s *sqlite.Stmt) int32 { + return s.ColumnInt32(0) + }) +} diff --git a/go.mod b/go.mod index 5f657f0..66cf6a9 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,12 @@ go 1.25.5 require ( github.com/disgoorg/disgo v0.19.6 + github.com/gen2brain/avif v0.6.0 github.com/go-chi/chi/v5 v5.3.0 github.com/google/go-cmp v0.7.0 github.com/junegunn/fzf v0.73.1 + golang.org/x/image v0.44.0 + golang.org/x/sync v0.21.0 zombiezen.com/go/sqlite v1.4.2 ) @@ -16,6 +19,7 @@ require ( github.com/disgoorg/omit v1.0.0 // indirect github.com/disgoorg/snowflake/v2 v2.0.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect @@ -25,6 +29,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/tools v0.46.0 // indirect diff --git a/go.sum b/go.sum index b5e9909..7696c8e 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzg github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gen2brain/avif v0.6.0 h1:/8WSgcU+IEF0jhKYsUZ/mzlziFuTeJFpIKBj2siTQps= +github.com/gen2brain/avif v0.6.0/go.mod h1:QgrYqdVE9y40PCfArK9VakcMIpYeDYpZmCSLkW6C1n8= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -42,16 +46,20 @@ github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxE github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=