Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh
| `games-service` | A new `games` service answers the games I played in the last 14 days at `GET /games/recent`, from the Exophase profile that `BAKHYARD_EXOPHASE_PLAYER` names, digits only; it runs on 8090 under `mise run up`, and its image builds with the others'. The fourth of `games-exophase`'s eleven steps (decision 303) | Done (#178) |
| `twitch-token` | The `games` service gets a Twitch app token for IGDB with the client credentials grant, keeps it in memory until 9 tenths of its life, asks Twitch one request at a time, and forgets a token that IGDB refuses. The fifth of `games-exophase`'s eleven steps (decision 304) | Done (#181) |
| `igdb-search` | The `games` service's IGDB client finds a title's cover: the top result of a search filtered to main games, expansions, remakes, remasters, expanded games and ports, or a game named exactly the title when the top result has another name; it asks one request at a time, at least 250 ms apart, and with a new token once when IGDB refuses one. The sixth of `games-exophase`'s eleven steps (decision 305) | Done (#183) |
| `games-covers` | Each game in the `games` service's answer carries its IGDB cover when `BAKHYARD_TWITCH_CLIENT_ID` and the Keychain's `twitch-client-secret` are set: each title's cover, or that IGDB has none, is kept a week, the first failure stops the asking until the next fetch, and a missing cover never fails the list. The seventh of `games-exophase`'s eleven steps (decision 306) | Done (#186) |
| `vault-edit-sync` | Editing the vault from Bakhyard (§8.2 Phase 6), by the vault session in `../bakhyard-vault/`, which adds its steps' rows under this one and then does `copilot-planner` (decision 195) | In progress |
| `vault-plan` | The vault session's first steps: where a save goes waits on the user's answer to §12's question on how edits get back to the Mac, so the version that a save checks and the editor come first; a note's version is the SHA-256 of its bytes, sent as its `ETag`, and a save is a `PUT` that sends it back in `If-Match` (decision 400) | Done (#167) |
| `note-etag` | notes answers each note with its version as a strong `ETag`, the SHA-256 of its bytes, and a matching `If-None-Match` with a 304 (decision 400) | Done (#169) |
Expand Down Expand Up @@ -1281,7 +1282,7 @@ Every item is pending unless it says done. The service an item lands in is in br
- `games-service`: a new `games` service answers the games I played in the last 14 days at `GET /games/recent` (decision 303). Done (#178).
- `twitch-token`: the service gets a Twitch app token for IGDB, keeps it and renews it before it expires (decision 304). Done (#181).
- `igdb-search`: the service's IGDB client finds a title's cover with a filtered search and an exact-name fallback (decisions 68 and 305), within IGDB's 4 requests a second. Done (#183).
- `games-covers`: each game in the answer carries its IGDB cover; each title is matched once and the match kept.
- `games-covers`: each game in the answer carries its IGDB cover; each title is matched once and the match kept for a week (decision 306). Done (#186).
- `homelab-games`: in the homelab repo, `games` runs on the laptop with its settings and secret, and the gateway's peers name it.
- `games-gateway`: the gateway forwards `/api/games` to `games`.
- `games-page`: the Games page lists the games, and marks them stale when the latest fetch failed (decision 67); the Games card's description in the app becomes §7's.
Expand Down Expand Up @@ -1816,6 +1817,15 @@ Decisions 300 to 399 are the `games-exophase` session's.
- **Not yet checked live**: the queries and headers follow the spike, IGDB's docs, and another IGDB client's working use of `name ~` without wildcards. IGDB itself waits for the user's Twitch app, which `games-covers` needs.
- **Rejected**: the exact-name search first, which would take any record with that name, such as an edition or a bundle, before the filtered search's better match; the fallback only when the filtered search finds nothing, which would keep the spike's 15th title's wrong match; requests in parallel up to IGDB's 8 open, which would need a rate limiter to save a few seconds on a dozen titles, each matched once; `golang.org/x/time/rate`, a new module for one gap; stripping the quotes from a title rather than escaping them, which would change the exact name; covers that Bakhyard stores and serves, which IGDB already serves.

306. **The covers** (`games-covers`): the lead's choices (2026-10-30), for the user to review.
- **The answer**: each game in `GET /games/recent` carries `cover`, the address of its cover on IGDB (decision 305), left out when it has none, as `playtimeMinutes` and `trophies` are. `api.Game` embeds `exophase.Game` and adds the cover, so each game stays one flat object and the Exophase client knows nothing of IGDB.
- **The settings**: as decision 300 has them. Without `BAKHYARD_TWITCH_CLIENT_ID`, the games come without covers and no secret is read; with it but no secret, the service exits 1 with `read the Twitch client secret:` and why. The ID isn't checked: Twitch refuses one it doesn't know, and the log says so. The secret is read as the service starts, even when Exophase is off.
- **The matches**: `services/games/internal/covers` keeps each title's cover, or that IGDB has none, for a week from when it asked, well within the 30 days that IGDB keeps an image it replaces (decision 305), so a new cover shows within a week. Only the latest list's titles stay, so the matches never outgrow the list. They live in memory (decision 300).
- **When**: as the list is fetched (decision 300), once Exophase has answered, a title at a time in the list's order, so the games played last get their covers first; a title that the list names twice is asked once. Fetches never overlap, since the cache polls one at a time (decision 302), so the matches need no lock.
- **Failures**: the first error stops the asking for that fetch. The games not asked yet, and the one that failed, go without covers, while the covers kept still show. The list itself doesn't fail, so the page shows no failure, and the next fetch, an hour later, asks again. The service logs `find covers` with the error, as home's feed logs `list the items`, unless the fetch's context has ended.
- **A known limit**: a title whose search alone fails, rather than IGDB as a whole, stops the asking at the same place at each fetch, so the titles after it get no cover until it leaves the list; the log names it.
- **Rejected**: the cover in `exophase.Game`, which would tie the Exophase client to IGDB; failing the fetch for a missing cover, which would keep the last list, stale, for want of a picture; asking on past a failure, which would add up to 10 seconds a title while IGDB or Twitch is down; finding the covers after answering, which would show them only with the next fetch, an hour later; keeping errors, which would hide a recovered IGDB until they expire; a `sync.Mutex`, which nothing contends for; an expired match as a fallback when asking fails, which would keep a second kind of match for a rare case.

Decisions 400 to 499 are the vault session's: `vault-edit-sync`, then `copilot-planner`.

400. **Editing notes, the first steps** (`vault-plan`, `note-etag`, `note-editor`): the lead's choices (2026-10-30), for the user to review.
Expand Down
21 changes: 19 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ cp mise.local.example.toml mise.local.toml
| `BAKHYARD_BITBUCKET_WORKSPACES` | pulls | The slugs of the workspaces to list your open pull requests in, separated by commas |
| `BAKHYARD_GITHUB_API` | github | GitHub's REST API, `https://api.github.com`, where github searches for your open pull requests, review requests and issues ([below](#github)) |
| `BAKHYARD_EXOPHASE_PLAYER` | games | The id of the Exophase profile whose games to list, digits only ([below](#exophase)) |
| `BAKHYARD_TWITCH_CLIENT_ID` | games | The client ID of the Twitch app with which games asks IGDB for the games' covers ([below](#igdb)) |
| `BAKHYARD_TEST_DATABASE` | Go tests | The test database's URL, password included ([below](#the-test-database)) |

The gateway doesn't start without its four settings, nor notes without its vault: each exits 1 and says what's missing. The gateway also waits for notes, notify, nudge, pulls, time and todos to start. Todos and time run without Jira when none of the three Jira settings is set, pulls without Bitbucket when none of the three Bitbucket settings is set, github without GitHub when `BAKHYARD_GITHUB_API` isn't set, and games without Exophase when `BAKHYARD_EXOPHASE_PLAYER` isn't set.
The gateway doesn't start without its four settings, nor notes without its vault: each exits 1 and says what's missing. The gateway also waits for notes, notify, nudge, pulls, time and todos to start. Todos and time run without Jira when none of the three Jira settings is set, pulls without Bitbucket when none of the three Bitbucket settings is set, github without GitHub when `BAKHYARD_GITHUB_API` isn't set, and games without Exophase when `BAKHYARD_EXOPHASE_PLAYER` isn't set and without covers when `BAKHYARD_TWITCH_CLIENT_ID` isn't set.

On the Mac, the gateway, todos, pulls, time and github read their secrets from the login Keychain, as the sections below show. On the laptop, which has no Keychain, `BAKHYARD_SECRETS` names a folder of files instead, each named as its secret's Keychain account (`google-client-secret`, `postgres-gateway`, `postgres-todos`, `postgres-notify`, `postgres-nudge`, `vapid-key`, `jira-token`, `bitbucket-token`, `github-token`) and holding the secret exactly, with no newline at the end: the files of the Kubernetes Secrets mounted there (decision 154). Leave it unset on the Mac.
On the Mac, the gateway, todos, pulls, time, github and games read their secrets from the login Keychain, as the sections below show. On the laptop, which has no Keychain, `BAKHYARD_SECRETS` names a folder of files instead, each named as its secret's Keychain account (`google-client-secret`, `postgres-gateway`, `postgres-todos`, `postgres-notify`, `postgres-nudge`, `vapid-key`, `jira-token`, `bitbucket-token`, `github-token`, `twitch-client-secret`) and holding the secret exactly, with no newline at the end: the files of the Kubernetes Secrets mounted there (decision 154). Leave it unset on the Mac.

## Tailscale

Expand Down Expand Up @@ -189,6 +190,22 @@ Games lists the games you played in the last 14 days, the last played first, wit

The id must be digits only; with anything else, games exits 1 and says so: `BAKHYARD_EXOPHASE_PLAYER: "…" is not an Exophase profile id, which is digits only`. Without it, `GET /games/recent` answers 404 `Exophase is not set up`. `mise run up` runs games on port 8090, where `GET /games/recent` answers the games, with when the latest fetch to succeed began and why the latest fetch failed, if it did. Games fetches the list only when asked: the first request waits for the first fetch, and later ones get the list at once, while one that finds it an hour old or more starts a fetch in the background. When a fetch fails, for example while Exophase is down, games keeps answering the last list it got, adds the failure, such as `list my Exophase games: 500 Internal Server Error`, which is also what Exophase answers for an id it doesn't know, `list my Exophase games: 403 Forbidden, a Cloudflare challenge` or, for a profile that isn't public, `list my Exophase games: Exophase has no public profile with this id`, and logs `poll Exophase` with it. The next fetch then waits 5 minutes, and twice as long after each failure in a row, up to 6 hours. The list lives in games' memory, so a restart fetches it again.

## IGDB

Games shows each game's cover from IGDB, which it asks as a Twitch app (decisions 68 and 304 to 306). IGDB's API is free for non-commercial use, under the terms of the Twitch Developer Service Agreement. To set it up:

1. Turn on two-factor authentication for your Twitch account, under [Security and Privacy](https://www.twitch.tv/settings/security): Twitch's developer console needs it.
2. In the [developer console](https://dev.twitch.tv/console), on the Applications tab, select Register Your Application. Give the app a name that no other Twitch app has, add the OAuth redirect URL `http://localhost`, which IGDB doesn't use, pick any category and the client type Confidential, then select Create. Select Manage beside the new app: the page shows its client ID. Select New Secret and copy the secret.
3. Put the client secret in the Keychain. With `-w` last, `security` asks for it:

```sh
security add-generic-password -s bakhyard -a twitch-client-secret -w
```

4. In `mise.local.toml`, set `BAKHYARD_TWITCH_CLIENT_ID` to the app's client ID, then restart games.

Without the setting, the games come without covers. With it but no secret in the Keychain, games exits 1 and says so: `read the Twitch client secret: no Keychain item with service bakhyard and account twitch-client-secret`. With both, each game in `GET /games/recent` carries `cover`, the address of its cover on IGDB, unless IGDB has none; the app loads the image from IGDB. Games finds the covers as it fetches the list, a title at a time and at most four requests a second, so the first request after a start waits for them too. It keeps each title's cover, or that IGDB has none, for a week. When IGDB or Twitch fails, the games whose covers it hasn't found go without until the next fetch, and games logs `find covers` with the failure, such as `find the cover of "…": ask IGDB: 503 Service Unavailable` or, for a client ID that Twitch doesn't know, `find the cover of "…": get a Twitch token: 400 Bad Request, invalid client`. The covers and Twitch's token live in games' memory, so a restart asks again. A new secret makes the old one fail at once: store it with `-U` added to the command in step 3, then restart games.

## Run

`mise run up` builds the services and runs them with process-compose. A service that exits restarts after 1 s, so a missing setting or secret shows up in that service's log every second until it's fixed.
Expand Down
3 changes: 3 additions & 0 deletions mise.local.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ BAKHYARD_VAULT = "/Users/you/Documents/Vault"
# The id of the Exophase profile whose games the games service lists, digits only: the
# playerProfileId in the profile page's source. Leave it out to run without Exophase.
# BAKHYARD_EXOPHASE_PLAYER = "1234567"
# The client ID of the Twitch app with which the games service asks IGDB for the games' covers;
# its client secret goes into the Keychain. Leave it out to run without covers.
# BAKHYARD_TWITCH_CLIENT_ID = "0123456789abcdefghijklmnopqrst"
# The Go tests' own database. Put its role's password in place of <password>.
BAKHYARD_TEST_DATABASE = "postgres://bakhyard_test:<password>@127.0.0.1:5433/bakhyard_test"
2 changes: 1 addition & 1 deletion process-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ processes:

games:
<<: *service
description: Lists the games I played lately on Exophase
description: Lists the games I played lately on Exophase, with their IGDB covers
command: bin/games
environment:
- BAKHYARD_LISTEN=tcp://127.0.0.1:8090
Expand Down
72 changes: 72 additions & 0 deletions services/games/covers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"context"
"fmt"
"log/slog"

"github.com/codedogapp/bakhyard/internal/secrets"
"github.com/codedogapp/bakhyard/services/games/internal/api"
"github.com/codedogapp/bakhyard/services/games/internal/covers"
"github.com/codedogapp/bakhyard/services/games/internal/exophase"
"github.com/codedogapp/bakhyard/services/games/internal/igdb"
"github.com/codedogapp/bakhyard/services/games/internal/twitch"
)

// twitchClientIDVar is the setting of the client ID of the Twitch app with which games asks IGDB
// for the games' covers (docs/setup.md). The games come without covers when it isn't set.
const twitchClientIDVar = "BAKHYARD_TWITCH_CLIENT_ID"

// twitchAccount is the Keychain account of the Twitch app's client secret, and the name of its
// file (decision 300).
const twitchAccount = "twitch-client-secret"

// coverCache returns the cache of the covers that IGDB finds for the Twitch app in the settings,
// which gets its tokens from Twitch with the secret that readSecret reads, or nil when the app
// isn't set.
func coverCache(
ctx context.Context, getenv func(string) string, readSecret secrets.Reader, addresses apis,
) (*covers.Cache, error) {
clientID := getenv(twitchClientIDVar)
if clientID == "" {
return nil, nil
}

secret, err := readSecret(ctx, twitchAccount)
if err != nil {
return nil, fmt.Errorf("read the Twitch client secret: %w", err)
}

tokens := twitch.New(addresses.twitch, clientID, secret)

return covers.New(igdb.New(addresses.igdb, clientID, tokens)), nil
}

// covered returns games, each with its cover from cache, or with none when cache is nil or finds
// none. It logs to logger why cache found no more covers, unless ctx has ended.
func covered(
ctx context.Context, games []exophase.Game, cache *covers.Cache, logger *slog.Logger,
) []api.Game {
answered := make([]api.Game, len(games))
titles := make([]string, len(games))

for i, game := range games {
answered[i].Game = game
titles[i] = game.Title
}

if cache == nil {
return answered
}

found, err := cache.Find(ctx, titles)
if err != nil && ctx.Err() == nil {
logger.ErrorContext(ctx, "find covers", "err", err)
}

for i, cover := range found {
answered[i].Cover = cover
}

return answered
}
Loading
Loading