Skip to content

Commit

Permalink
Add support for RetroAchievements
Browse files Browse the repository at this point in the history
Signed-off-by: Marcus Crane <marcus@utf9k.net>
marcus-crane committed Sep 23, 2024

Verified

This commit was signed with the committer’s verified signature.
marcus-crane Marcus Crane
1 parent 3dc3ebd commit 1e09abf
Showing 4 changed files with 166 additions and 16 deletions.
24 changes: 15 additions & 9 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -6,15 +6,16 @@ import (
)

type Config struct {
Anilist AnilistConfig
Gunslinger GunslingerConfig
Miniflux MinifluxConfig
Plex PlexConfig
Pushover PushoverConfig
Readwise ReadwiseConfig
Spotify SpotifyConfig
Steam SteamConfig
Trakt TraktConfig
Anilist AnilistConfig
Gunslinger GunslingerConfig
Miniflux MinifluxConfig
Plex PlexConfig
Pushover PushoverConfig
Readwise ReadwiseConfig
RetroAchievements RetroAchievementsConfig
Spotify SpotifyConfig
Steam SteamConfig
Trakt TraktConfig
}

type AnilistConfig struct {
@@ -47,6 +48,11 @@ type ReadwiseConfig struct {
Token string `env:"READWISE_TOKEN"`
}

type RetroAchievementsConfig struct {
Username string `env:"RETROACHIEVEMENTS_USERNAME"`
Token string `env:"RETROACHIEVEMENTS_TOKEN"`
}

type SpotifyConfig struct {
ConnectPlayerName string `env:"SPOTIFY_CONNECT_PLAYER_NAME"`
ClientId string `env:"SPOTIFY_CLIENT_ID"`
5 changes: 4 additions & 1 deletion jobs.go
Original file line number Diff line number Diff line change
@@ -5,10 +5,12 @@ import (
"time"

"github.com/go-co-op/gocron"
"github.com/marcus-crane/gunslinger/anilist"
"github.com/marcus-crane/gunslinger/config"
"github.com/marcus-crane/gunslinger/db"
"github.com/marcus-crane/gunslinger/playback"
"github.com/marcus-crane/gunslinger/plex"
"github.com/marcus-crane/gunslinger/retroachievements"
"github.com/marcus-crane/gunslinger/spotify"
"github.com/marcus-crane/gunslinger/steam"
"github.com/marcus-crane/gunslinger/trakt"
@@ -21,8 +23,9 @@ func SetupInBackground(cfg config.Config, ps *playback.PlaybackSystem, store db.

go spotify.SetupSpotifyPoller(cfg, ps, store)

s.Every(1).Minutes().Do(retroachievements.GetCurrentlyPlaying, cfg, ps, client)
s.Every(1).Seconds().Do(plex.GetCurrentlyPlaying, cfg, ps, client)
// s.Every(15).Seconds().Do(anilist.GetRecentlyReadManga, ps, store, client) // Rate limit: 90 req/sec
s.Every(15).Seconds().Do(anilist.GetRecentlyReadManga, ps, store, client) // Rate limit: 90 req/sec
s.Every(15).Seconds().Do(steam.GetCurrentlyPlaying, cfg, ps, client)
s.Every(15).Seconds().Do(trakt.GetCurrentlyPlaying, cfg, ps, client)
s.Every(15).Seconds().Do(trakt.GetCurrentlyListening, cfg, ps, client)
13 changes: 7 additions & 6 deletions playback/interface.go
Original file line number Diff line number Diff line change
@@ -40,12 +40,13 @@ const (
type Source string

const (
Anilist Source = "anilist"
Plex Source = "plex"
Spotify Source = "spotify"
Steam Source = "steam"
Trakt Source = "trakt"
TraktCasts Source = "traktcasts"
Anilist Source = "anilist"
Plex Source = "plex"
RetroAchievements Source = "retroachievements"
Spotify Source = "spotify"
Steam Source = "steam"
Trakt Source = "trakt"
TraktCasts Source = "traktcasts"
)

// PlaybackEntry is a unique instance of a piece of media being played. If a movie is watched 5 times,
140 changes: 140 additions & 0 deletions retroachievements/retroachievements.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package retroachievements

import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"

"github.com/marcus-crane/gunslinger/config"
"github.com/marcus-crane/gunslinger/playback"
"github.com/marcus-crane/gunslinger/utils"
)

const (
ProfileURL = "https://retroachievements.org/API/API_GetUserSummary.php?u=%s&g=1&a=2&y=%s"
)

type Profile struct {
RichPresenceMsg string `json:"RichPresenceMsg"`
LastGameID int `json:"LastGameID"`
RecentlyPlayed []RecentlyPlayedGame `json:"RecentlyPlayed"`
LastGame LastPlayedGame `json:"LastGame"`
}

// TODO: Profile has embedded last game but doesn't seem populated?

type RecentlyPlayedList []RecentlyPlayedGame

type RecentlyPlayedGame struct {
GameID int `json:"GameID"`
LastPlayed string `json:"LastPlayed"`
}

type LastPlayedGame struct {
ID int `json:"ID"`
Title string `json:"Title"`
ImageBoxArt string `json:"ImageBoxArt"`
Developer string `json:"Developer"`
}

func GetCurrentlyPlaying(cfg config.Config, ps *playback.PlaybackSystem, client http.Client) {
url := fmt.Sprintf(ProfileURL, cfg.RetroAchievements.Username, cfg.RetroAchievements.Token)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
slog.Error("Failed to prepare RetroAchievements request",
slog.String("stack", err.Error()),
)
return
}
req.Header = http.Header{
"Accept": []string{"application/json"},
"Content-Type": []string{"application/json"},
"User-Agent": []string{utils.UserAgent},
}
res, err := http.DefaultClient.Do(req)
if err != nil {
slog.Error("Failed to contact RetroAchievements for updates",
slog.String("stack", err.Error()),
)
return
}
defer res.Body.Close()

body, err := io.ReadAll(res.Body)
if err != nil {
slog.Error("Failed to read RetroAchievements response",
slog.String("stack", err.Error()),
)
return
}
var raProfile Profile
if err = json.Unmarshal(body, &raProfile); err != nil {
slog.Error("Error fetching RetroAchievements data",
slog.String("stack", err.Error()),
)
return
}
var lastPlayed RecentlyPlayedGame
for i, game := range raProfile.RecentlyPlayed {
if i == 0 {
lastPlayed = game
}
}
// Somehow we got nothing played recently
if lastPlayed.GameID == 0 {
ps.DeactivateBySource(string(playback.RetroAchievements))
return
}

if raProfile.LastGame.ID != lastPlayed.GameID {
// We know the last game but seemingly a newer game exists. We need the timestamp to know whether it's active.
ps.DeactivateBySource(string(playback.RetroAchievements))
return
}

// 2024-09-23 10:12:39

imageUrl := fmt.Sprintf("https://media.retroachievements.org%s", raProfile.LastGame.ImageBoxArt)
slog.With(slog.String("image_url", imageUrl)).Info("Built image link")

image, extension, domColours, err := utils.ExtractImageContent(imageUrl)
if err != nil {
slog.Error("Failed to extract image content",
slog.String("stack", err.Error()),
slog.String("image_url", imageUrl),
)
return
}

imageLocation, _ := utils.BytesToGUIDLocation(image, extension)

update := playback.Update{
MediaItem: playback.MediaItem{
Title: raProfile.LastGame.Title,
Subtitle: raProfile.LastGame.Developer,
Category: string(playback.Gaming),
Duration: 0,
Source: string(playback.RetroAchievements),
Image: imageLocation,
DominantColours: domColours,
},
Status: playback.StatusPlaying,
}

if err := ps.UpdatePlaybackState(update); err != nil {
slog.Error("Failed to save RetroAchievements update",
slog.String("stack", err.Error()),
slog.String("title", update.MediaItem.Title))
}

hash := playback.GenerateMediaID(&update)
if err := utils.SaveCover(cfg, hash, image, extension); err != nil {
slog.Error("Failed to save cover for RetroAchievements",
slog.String("stack", err.Error()),
slog.String("guid", hash),
slog.String("title", update.MediaItem.Title),
)
}
}

0 comments on commit 1e09abf

Please sign in to comment.