Skip to content

Commit 05c8d8e

Browse files
Added HowLongToBeat support
1 parent a765540 commit 05c8d8e

File tree

11 files changed

+427
-33
lines changed

11 files changed

+427
-33
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ This Go project aims to automate the completion of video game information in a N
5050
- Release date:
5151
- Name: Release date
5252
- Type: Date
53+
- Time to complete (Main Story)
54+
- Name: Time to complete (Main Story)
55+
- Type: Text
56+
- Time to complete (Main + Sides)
57+
- Name: Time to complete (Main + Sides)
58+
- Type: Text
59+
- Time to complete (Completionist)
60+
- Name: Time to complete (Completionist)
61+
- Type: Text
5362

5463
3. Create a private Notion integration on your account by following the [getting started page](https://developers.notion.com/docs/create-a-notion-integration#create-your-integration-in-notion) before the "Setting up the demo locally" step.
5564

cmd/updater/main.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"notion-igdb-autocomplete/choose"
88
"notion-igdb-autocomplete/config"
9+
"notion-igdb-autocomplete/howlongtobeat"
910
"notion-igdb-autocomplete/igdb"
1011
"notion-igdb-autocomplete/notion"
1112

@@ -33,12 +34,14 @@ func main() {
3334
notionClient := notion.NewClient(config.NotionAPISecret)
3435
log.Println("Successfully created Notion client!")
3536

37+
hltbClient := howlongtobeat.NewClient()
38+
3639
server := gin.Default()
3740
server.GET("/heartbeat", func(ctx *gin.Context) {
3841
ctx.JSON(http.StatusOK, gin.H{"message": "I'm alive!"})
3942
})
4043

41-
server.PUT("/", func(ctx *gin.Context) {
44+
server.PUT("/game_infos", func(ctx *gin.Context) {
4245
var payload body
4346

4447
err := ctx.ShouldBindJSON(&payload)
@@ -53,7 +56,7 @@ func main() {
5356
return
5457
}
5558

56-
updatedPage, err := notionClient.Page(payload.PageID).Update(game)
59+
updatedPage, err := notionClient.Page(payload.PageID).UpdateGameInfos(game)
5760
if err != nil {
5861
ctx.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()})
5962
return
@@ -62,6 +65,31 @@ func main() {
6265
ctx.JSON(http.StatusOK, gin.H{"data": fmt.Sprintf("Updated page %s with game %s infos", updatedPage.ID, game.Name)})
6366
})
6467

68+
server.PUT("/time_to_beat", func(ctx *gin.Context) {
69+
var payload body
70+
71+
err := ctx.ShouldBindJSON(&payload)
72+
if err != nil {
73+
ctx.JSON(http.StatusBadRequest, gin.H{"message": err.Error()})
74+
return
75+
}
76+
77+
hltbGame, err := searchHowLongToBeatGame(payload.Search, &hltbClient)
78+
if err != nil {
79+
ctx.JSON(http.StatusNotFound, gin.H{"message": err.Error()})
80+
}
81+
82+
updatedPage, err := notionClient.Page(payload.PageID).UpdateTimeToBeat(hltbGame)
83+
if err != nil {
84+
ctx.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()})
85+
return
86+
}
87+
88+
ctx.JSON(http.StatusOK, gin.H{"data": fmt.Sprintf("Updated %s(page %s) with time to beat infos", payload.Search, updatedPage.ID)})
89+
90+
ctx.Status(http.StatusOK)
91+
})
92+
6593
err = server.Run(fmt.Sprintf("0.0.0.0:%d", config.UpdaterPort))
6694
if err != nil {
6795
log.Fatalf("Unable to start server: %s\n", err)
@@ -81,3 +109,16 @@ func searchIgdbGame(gameName string, client *igdb.Client) (*igdb.Game, error) {
81109

82110
return choose.Game(gameName, results), nil
83111
}
112+
113+
func searchHowLongToBeatGame(gameName string, client *howlongtobeat.Client) (*howlongtobeat.Game, error) {
114+
games, err := client.SearchGame(gameName)
115+
if err != nil {
116+
log.Fatalf("cannot find time infos: %s", err)
117+
}
118+
119+
if len(games) <= 0 {
120+
return &howlongtobeat.Game{Name: fmt.Sprintf("Not found (%s)", gameName), CompletionMain: 0, CompletionPlus: 0, CompletionFull: 0}, nil
121+
}
122+
123+
return &games[0], nil
124+
}

cmd/watcher/main.go

Lines changed: 89 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,59 +27,124 @@ func main() {
2727
log.Println("Successfully loaded config!")
2828
}
2929

30+
updater := updaterClient{
31+
GameInfosUrl: fmt.Sprintf("%s%s", config.UpdaterURL(), "game_infos"),
32+
TimeToBeatUrl: fmt.Sprintf("%s%s", config.UpdaterURL(), "time_to_beat"),
33+
}
34+
3035
notionClient := notion.NewClient(config.NotionAPISecret)
3136
log.Println("Successfully created Notion client!")
3237

33-
titleCleaner := strings.NewReplacer("{{", "", "}}", "")
38+
gamesToUpdate := make(chan updateRequestBody, 20)
39+
TtbToUpdate := make(chan updateRequestBody, 20)
40+
41+
runningGameUpdates := make(map[string]notionapi.ObjectID)
42+
runningTimeToBeatUpdates := make(map[string]notionapi.ObjectID)
43+
44+
go updater.updateGameInfos(gamesToUpdate, runningGameUpdates)
45+
go updater.updateTimeToBeat(TtbToUpdate, runningTimeToBeatUpdates)
3446

3547
log.Println("Looking for pages to update...")
36-
for range time.Tick(time.Duration(config.WatcherTickDelay)) {
37-
entries, err := notionClient.Database(config.NotionPageID).GetEntries()
48+
for range time.Tick(time.Duration(config.WatcherTickDelay) * time.Second) {
49+
emptyGamesEntries, err := notionClient.Database(config.NotionPageID).GetEmptyGamesEntries()
3850
if err != nil {
39-
log.Fatalf("Unable to fetch pages: %s\n", err)
51+
log.Fatalf("Unable to fetch empty games pages: %s\n", err)
4052
}
4153

42-
for _, entry := range entries {
43-
id := entry.ID
44-
title := entry.Properties["Title"].(*notionapi.TitleProperty).Title[0].Text.Content
45-
cleanTitle := titleCleaner.Replace(title)
46-
47-
err = callUpdater(config.UpdaterURL(), updateRequestBody{PageID: id.String(), Search: cleanTitle})
48-
if err != nil {
49-
log.Printf("Unable to update page '%s': %s\n", id, err)
50-
} else {
51-
log.Printf("page '%s' successfully updated!", id)
52-
}
54+
timeToBeatEntries, err := notionClient.Database(config.NotionPageID).GetEmptyTimeToBeatEntries()
55+
if err != nil {
56+
log.Fatalf("Unable to fetch time to beat games pages: %s\n", err)
57+
}
58+
59+
go enqueueGameInfosRequests(gamesToUpdate, emptyGamesEntries, runningGameUpdates)
60+
go enqueueTimeToBeatRequests(TtbToUpdate, timeToBeatEntries, runningTimeToBeatUpdates)
61+
}
62+
}
63+
64+
type updaterClient struct {
65+
GameInfosUrl string
66+
TimeToBeatUrl string
67+
}
68+
69+
func enqueueGameInfosRequests(c chan<- updateRequestBody, entries []notionapi.Page, runningUpdates map[string]notionapi.ObjectID) {
70+
titleCleaner := strings.NewReplacer("{{", "", "}}", "")
71+
72+
for _, entry := range entries {
73+
id := entry.ID
74+
title := entry.Properties["Title"].(*notionapi.TitleProperty).Title[0].Text.Content
75+
cleanTitle := titleCleaner.Replace(title)
76+
77+
if _, ok := runningUpdates[id.String()]; !ok {
78+
runningUpdates[id.String()] = id
79+
80+
c <- updateRequestBody{PageID: id.String(), Search: cleanTitle}
5381
}
5482
}
5583
}
5684

57-
func callUpdater(updaterURL string, payload updateRequestBody) error {
58-
reqBody, err := json.Marshal(payload)
85+
func enqueueTimeToBeatRequests(c chan<- updateRequestBody, entries []notionapi.Page, runningUpdates map[string]notionapi.ObjectID) {
86+
for _, entry := range entries {
87+
id := entry.ID
88+
title := entry.Properties["Title"].(*notionapi.TitleProperty).Title[0].Text.Content
89+
90+
if _, ok := runningUpdates[id.String()]; !ok {
91+
runningUpdates[id.String()] = id
92+
93+
c <- updateRequestBody{PageID: id.String(), Search: title}
94+
}
95+
}
96+
}
97+
98+
func (uc updaterClient) updateGameInfos(c chan updateRequestBody, runningUpdates map[string]notionapi.ObjectID) {
99+
for updateRequest := range c {
100+
if err := uc.call(uc.GameInfosUrl, updateRequest); err != nil {
101+
log.Printf("UpdateGameInfos failed: %s", err)
102+
} else {
103+
log.Printf("Successfully updated page '%s' !", updateRequest.PageID)
104+
}
105+
106+
delete(runningUpdates, updateRequest.PageID)
107+
}
108+
}
109+
110+
func (uc updaterClient) updateTimeToBeat(c chan updateRequestBody, runningUpdates map[string]notionapi.ObjectID) {
111+
for updateRequest := range c {
112+
if err := uc.call(uc.TimeToBeatUrl, updateRequest); err != nil {
113+
log.Printf("updateTimeToBeat failed: %s", err)
114+
} else {
115+
log.Printf("Successfully updated page '%s' !", updateRequest.PageID)
116+
}
117+
118+
delete(runningUpdates, updateRequest.PageID)
119+
}
120+
}
121+
122+
func (uc updaterClient) call(url string, updateRequest updateRequestBody) error {
123+
reqBody, err := json.Marshal(updateRequest)
59124
if err != nil {
60-
return err
125+
return fmt.Errorf("marshall error: err='%s', pageID='%s', body='%v'\n", err, updateRequest.PageID, updateRequest)
61126
}
62127

63128
httpClient := &http.Client{}
64-
req, err := http.NewRequest("PUT", updaterURL, strings.NewReader(string(reqBody)))
129+
req, err := http.NewRequest("PUT", url, strings.NewReader(string(reqBody)))
65130
if err != nil {
66-
return err
131+
return fmt.Errorf("creation error: err='%s', pageID='%s', body='%s'\n", err, updateRequest.PageID, string(reqBody))
67132
}
68133

69-
log.Printf("Requesting update: %s\n", reqBody)
134+
log.Printf("Requesting %s => %s\n", url, reqBody)
70135
resp, err := httpClient.Do(req)
71136
if err != nil {
72-
return err
137+
return fmt.Errorf("request failed: err='%s', response='%v'\n", err, resp)
73138
}
74139
defer resp.Body.Close()
75140

76141
body, err := io.ReadAll(resp.Body)
77142
if err != nil {
78-
return err
143+
return fmt.Errorf("invalid request response: err='%s', response='%v'\n", err, resp)
79144
}
80145

81146
if resp.StatusCode != 200 {
82-
return fmt.Errorf("%s: %s", resp.Status, body)
147+
return fmt.Errorf("cannot update: status='%s', body='%s', pageID='%s'\n", resp.Status, body, updateRequest.PageID)
83148
}
84149

85150
return nil

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ services:
2525
test: "wget --no-verbose --tries=1 http://127.0.0.1:${UPDATER_PORT}/heartbeat || exit 1"
2626
interval: 2s
2727
timeout: 1s
28-
retries: 3
28+
retries: 3
2929

3030
networks:
3131
igdb-autocomplete:

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require github.com/joho/godotenv v1.5.1
77
require (
88
github.com/agnivade/levenshtein v1.1.1
99
github.com/caarlos0/env/v9 v9.0.0
10+
github.com/corpix/uarand v0.2.0
1011
github.com/gin-gonic/gin v1.9.1
1112
github.com/jomei/notionapi v1.12.9
1213
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0X
1010
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
1111
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
1212
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
13+
github.com/corpix/uarand v0.2.0 h1:U98xXwud/AVuCpkpgfPF7J5TQgr7R5tqT8VZP5KWbzE=
14+
github.com/corpix/uarand v0.2.0/go.mod h1:/3Z1QIqWkDIhf6XWn/08/uMHoQ8JUoTIKc2iPchBOmM=
1315
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1416
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1517
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

howlongtobeat/client.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package howlongtobeat
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/corpix/uarand"
11+
)
12+
13+
type Client struct {
14+
UserAgent string
15+
}
16+
17+
func NewClient() Client {
18+
return Client{
19+
UserAgent: uarand.GetRandom(),
20+
}
21+
}
22+
23+
func (c Client) SearchGame(gameName string) (Games, error) {
24+
request := NewRequest(gameName)
25+
games := Games{}
26+
httpClient := &http.Client{}
27+
28+
requestBody, err := json.Marshal(request.Body())
29+
if err != nil {
30+
return games, fmt.Errorf("invalid request body: %s", err)
31+
}
32+
33+
req, _ := http.NewRequest("POST", "https://howlongtobeat.com/api/search", strings.NewReader(string(requestBody)))
34+
req.Header = map[string][]string{
35+
"Content-Type": {"application/json"},
36+
"User-Agent": {c.UserAgent},
37+
"Referer": {"https://howlongtobeat.com/"},
38+
}
39+
40+
resp, err := httpClient.Do(req)
41+
if err != nil {
42+
return games, err
43+
}
44+
defer resp.Body.Close()
45+
46+
body, err := io.ReadAll(resp.Body)
47+
if err != nil {
48+
return games, err
49+
}
50+
51+
if resp.StatusCode != 200 {
52+
return games, fmt.Errorf("%s: %s", resp.Status, body)
53+
}
54+
55+
var result Result
56+
err = json.Unmarshal(body, &result)
57+
if err != nil {
58+
return games, err
59+
}
60+
61+
return result.Data, nil
62+
}

howlongtobeat/game.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package howlongtobeat
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"time"
7+
)
8+
9+
type Result struct {
10+
Count int `json:"count,required"`
11+
Data Games `json:"data,required"`
12+
}
13+
14+
type Game struct {
15+
Name string `json:"game_name,required"`
16+
CompletionMain int `json:"comp_main,required"`
17+
CompletionPlus int `json:"comp_plus,required"`
18+
CompletionFull int `json:"comp_100,required"`
19+
}
20+
21+
type Games []Game
22+
23+
func (g Game) ReadableCompletion(rawCompletion int) string {
24+
duration := time.Duration(rawCompletion * int(time.Second))
25+
hours := int(math.Round(duration.Hours()))
26+
27+
if hours <= 0 {
28+
return "Not available"
29+
}
30+
31+
return fmt.Sprintf("%dh", hours)
32+
}

0 commit comments

Comments
 (0)