Add Steam integration showing online status and recent games
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled

Fetches player summary and recently played games from Steam API with
5-minute server-side caching. Displays in the home sidebar with online
indicator and game artwork.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 01:59:34 +00:00
parent 747563c6c9
commit 264df132df
13 changed files with 772 additions and 2 deletions

View File

@@ -117,6 +117,7 @@ type ComplexityRoot struct {
RowingSessions func(childComplexity int) int RowingSessions func(childComplexity int) int
SpotifyListening func(childComplexity int) int SpotifyListening func(childComplexity int) int
SpotifyRecent func(childComplexity int) int SpotifyRecent func(childComplexity int) int
SteamStatus func(childComplexity int) int
User func(childComplexity int, id int) int User func(childComplexity int, id int) int
Users func(childComplexity int) int Users func(childComplexity int) int
} }
@@ -160,6 +161,19 @@ type ComplexityRoot struct {
Name func(childComplexity int) int Name func(childComplexity int) int
} }
SteamGame struct {
AppID func(childComplexity int) int
HeaderImageURL func(childComplexity int) int
Name func(childComplexity int) int
Playtime2Weeks func(childComplexity int) int
PlaytimeForever func(childComplexity int) int
}
SteamStatus struct {
Online func(childComplexity int) int
RecentGames func(childComplexity int) int
}
User struct { User struct {
Admin func(childComplexity int) int Admin func(childComplexity int) int
CreatedAt func(childComplexity int) int CreatedAt func(childComplexity int) int
@@ -208,6 +222,7 @@ type QueryResolver interface {
SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error) SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error)
SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error)
GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error) GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error)
SteamStatus(ctx context.Context) (*model.SteamStatus, error)
Me(ctx context.Context) (*models.User, error) Me(ctx context.Context) (*models.User, error)
} }
type RowingResolver interface { type RowingResolver interface {
@@ -598,6 +613,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
} }
return e.ComplexityRoot.Query.SpotifyRecent(childComplexity), true return e.ComplexityRoot.Query.SpotifyRecent(childComplexity), true
case "Query.steamStatus":
if e.ComplexityRoot.Query.SteamStatus == nil {
break
}
return e.ComplexityRoot.Query.SteamStatus(childComplexity), true
case "Query.user": case "Query.user":
if e.ComplexityRoot.Query.User == nil { if e.ComplexityRoot.Query.User == nil {
break break
@@ -731,6 +752,50 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.ComplexityRoot.SpotifyTrack.Name(childComplexity), true return e.ComplexityRoot.SpotifyTrack.Name(childComplexity), true
case "SteamGame.appId":
if e.ComplexityRoot.SteamGame.AppID == nil {
break
}
return e.ComplexityRoot.SteamGame.AppID(childComplexity), true
case "SteamGame.headerImageUrl":
if e.ComplexityRoot.SteamGame.HeaderImageURL == nil {
break
}
return e.ComplexityRoot.SteamGame.HeaderImageURL(childComplexity), true
case "SteamGame.name":
if e.ComplexityRoot.SteamGame.Name == nil {
break
}
return e.ComplexityRoot.SteamGame.Name(childComplexity), true
case "SteamGame.playtime2Weeks":
if e.ComplexityRoot.SteamGame.Playtime2Weeks == nil {
break
}
return e.ComplexityRoot.SteamGame.Playtime2Weeks(childComplexity), true
case "SteamGame.playtimeForever":
if e.ComplexityRoot.SteamGame.PlaytimeForever == nil {
break
}
return e.ComplexityRoot.SteamGame.PlaytimeForever(childComplexity), true
case "SteamStatus.online":
if e.ComplexityRoot.SteamStatus.Online == nil {
break
}
return e.ComplexityRoot.SteamStatus.Online(childComplexity), true
case "SteamStatus.recentGames":
if e.ComplexityRoot.SteamStatus.RecentGames == nil {
break
}
return e.ComplexityRoot.SteamStatus.RecentGames(childComplexity), true
case "User.admin": case "User.admin":
if e.ComplexityRoot.User.Admin == nil { if e.ComplexityRoot.User.Admin == nil {
break break
@@ -850,7 +915,7 @@ func newExecutionContext(
} }
} }
//go:embed "schema/activity.graphql" "schema/auth.graphql" "schema/favorite.graphql" "schema/gitea.graphql" "schema/message.graphql" "schema/post.graphql" "schema/rowing.graphql" "schema/schema.graphql" "schema/spotify.graphql" "schema/user.graphql" //go:embed "schema/activity.graphql" "schema/auth.graphql" "schema/favorite.graphql" "schema/gitea.graphql" "schema/message.graphql" "schema/post.graphql" "schema/rowing.graphql" "schema/schema.graphql" "schema/spotify.graphql" "schema/steam.graphql" "schema/user.graphql"
var sourcesFS embed.FS var sourcesFS embed.FS
func sourceData(filename string) string { func sourceData(filename string) string {
@@ -871,6 +936,7 @@ var sources = []*ast.Source{
{Name: "schema/rowing.graphql", Input: sourceData("schema/rowing.graphql"), BuiltIn: false}, {Name: "schema/rowing.graphql", Input: sourceData("schema/rowing.graphql"), BuiltIn: false},
{Name: "schema/schema.graphql", Input: sourceData("schema/schema.graphql"), BuiltIn: false}, {Name: "schema/schema.graphql", Input: sourceData("schema/schema.graphql"), BuiltIn: false},
{Name: "schema/spotify.graphql", Input: sourceData("schema/spotify.graphql"), BuiltIn: false}, {Name: "schema/spotify.graphql", Input: sourceData("schema/spotify.graphql"), BuiltIn: false},
{Name: "schema/steam.graphql", Input: sourceData("schema/steam.graphql"), BuiltIn: false},
{Name: "schema/user.graphql", Input: sourceData("schema/user.graphql"), BuiltIn: false}, {Name: "schema/user.graphql", Input: sourceData("schema/user.graphql"), BuiltIn: false},
} }
var parsedSchema = gqlparser.MustLoadSchema(sources...) var parsedSchema = gqlparser.MustLoadSchema(sources...)
@@ -2985,6 +3051,41 @@ func (ec *executionContext) fieldContext_Query_giteaFeed(_ context.Context, fiel
return fc, nil return fc, nil
} }
func (ec *executionContext) _Query_steamStatus(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_Query_steamStatus,
func(ctx context.Context) (any, error) {
return ec.Resolvers.Query().SteamStatus(ctx)
},
nil,
ec.marshalOSteamStatus2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐSteamStatus,
true,
false,
)
}
func (ec *executionContext) fieldContext_Query_steamStatus(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Query",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "online":
return ec.fieldContext_SteamStatus_online(ctx, field)
case "recentGames":
return ec.fieldContext_SteamStatus_recentGames(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type SteamStatus", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -3686,6 +3787,221 @@ func (ec *executionContext) fieldContext_SpotifyTrack_album(_ context.Context, f
return fc, nil return fc, nil
} }
func (ec *executionContext) _SteamGame_appId(ctx context.Context, field graphql.CollectedField, obj *model.SteamGame) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SteamGame_appId,
func(ctx context.Context) (any, error) {
return obj.AppID, nil
},
nil,
ec.marshalNInt2int,
true,
true,
)
}
func (ec *executionContext) fieldContext_SteamGame_appId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SteamGame",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Int does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _SteamGame_name(ctx context.Context, field graphql.CollectedField, obj *model.SteamGame) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SteamGame_name,
func(ctx context.Context) (any, error) {
return obj.Name, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_SteamGame_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SteamGame",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _SteamGame_playtime2Weeks(ctx context.Context, field graphql.CollectedField, obj *model.SteamGame) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SteamGame_playtime2Weeks,
func(ctx context.Context) (any, error) {
return obj.Playtime2Weeks, nil
},
nil,
ec.marshalNInt2int,
true,
true,
)
}
func (ec *executionContext) fieldContext_SteamGame_playtime2Weeks(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SteamGame",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Int does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _SteamGame_playtimeForever(ctx context.Context, field graphql.CollectedField, obj *model.SteamGame) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SteamGame_playtimeForever,
func(ctx context.Context) (any, error) {
return obj.PlaytimeForever, nil
},
nil,
ec.marshalNInt2int,
true,
true,
)
}
func (ec *executionContext) fieldContext_SteamGame_playtimeForever(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SteamGame",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Int does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _SteamGame_headerImageUrl(ctx context.Context, field graphql.CollectedField, obj *model.SteamGame) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SteamGame_headerImageUrl,
func(ctx context.Context) (any, error) {
return obj.HeaderImageURL, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_SteamGame_headerImageUrl(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SteamGame",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _SteamStatus_online(ctx context.Context, field graphql.CollectedField, obj *model.SteamStatus) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SteamStatus_online,
func(ctx context.Context) (any, error) {
return obj.Online, nil
},
nil,
ec.marshalNBoolean2bool,
true,
true,
)
}
func (ec *executionContext) fieldContext_SteamStatus_online(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SteamStatus",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _SteamStatus_recentGames(ctx context.Context, field graphql.CollectedField, obj *model.SteamStatus) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SteamStatus_recentGames,
func(ctx context.Context) (any, error) {
return obj.RecentGames, nil
},
nil,
ec.marshalNSteamGame2ᚕᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐSteamGameᚄ,
true,
true,
)
}
func (ec *executionContext) fieldContext_SteamStatus_recentGames(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SteamStatus",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "appId":
return ec.fieldContext_SteamGame_appId(ctx, field)
case "name":
return ec.fieldContext_SteamGame_name(ctx, field)
case "playtime2Weeks":
return ec.fieldContext_SteamGame_playtime2Weeks(ctx, field)
case "playtimeForever":
return ec.fieldContext_SteamGame_playtimeForever(ctx, field)
case "headerImageUrl":
return ec.fieldContext_SteamGame_headerImageUrl(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type SteamGame", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) { func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -6382,6 +6698,25 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
} }
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "steamStatus":
field := field
innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Query_steamStatus(ctx, field)
return res
}
rrm := func(ctx context.Context) graphql.Marshaler {
return ec.OperationContext.RootResolverMiddleware(ctx,
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "me": case "me":
field := field field := field
@@ -6851,6 +7186,109 @@ func (ec *executionContext) _SpotifyTrack(ctx context.Context, sel ast.Selection
return out return out
} }
var steamGameImplementors = []string{"SteamGame"}
func (ec *executionContext) _SteamGame(ctx context.Context, sel ast.SelectionSet, obj *model.SteamGame) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, steamGameImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("SteamGame")
case "appId":
out.Values[i] = ec._SteamGame_appId(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "name":
out.Values[i] = ec._SteamGame_name(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "playtime2Weeks":
out.Values[i] = ec._SteamGame_playtime2Weeks(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "playtimeForever":
out.Values[i] = ec._SteamGame_playtimeForever(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "headerImageUrl":
out.Values[i] = ec._SteamGame_headerImageUrl(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.Deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.ProcessDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var steamStatusImplementors = []string{"SteamStatus"}
func (ec *executionContext) _SteamStatus(ctx context.Context, sel ast.SelectionSet, obj *model.SteamStatus) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, steamStatusImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("SteamStatus")
case "online":
out.Values[i] = ec._SteamStatus_online(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "recentGames":
out.Values[i] = ec._SteamStatus_recentGames(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.Deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.ProcessDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var userImplementors = []string{"User"} var userImplementors = []string{"User"}
func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *models.User) graphql.Marshaler { func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *models.User) graphql.Marshaler {
@@ -7603,6 +8041,32 @@ func (ec *executionContext) marshalNSpotifyTrack2ᚖadamᚑfrenchᚗcoᚗukᚋba
return ec._SpotifyTrack(ctx, sel, v) return ec._SpotifyTrack(ctx, sel, v)
} }
func (ec *executionContext) marshalNSteamGame2ᚕᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐSteamGameᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.SteamGame) graphql.Marshaler {
ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler {
fc := graphql.GetFieldContext(ctx)
fc.Result = &v[i]
return ec.marshalNSteamGame2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐSteamGame(ctx, sel, v[i])
})
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret
}
func (ec *executionContext) marshalNSteamGame2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐSteamGame(ctx context.Context, sel ast.SelectionSet, v *model.SteamGame) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._SteamGame(ctx, sel, v)
}
func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) { func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) {
res, err := graphql.UnmarshalString(v) res, err := graphql.UnmarshalString(v)
return res, graphql.ErrorOnPath(ctx, err) return res, graphql.ErrorOnPath(ctx, err)
@@ -7888,6 +8352,13 @@ func (ec *executionContext) marshalOSpotifyTrack2ᚖadamᚑfrenchᚗcoᚗukᚋba
return ec._SpotifyTrack(ctx, sel, v) return ec._SpotifyTrack(ctx, sel, v)
} }
func (ec *executionContext) marshalOSteamStatus2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐSteamStatus(ctx context.Context, sel ast.SelectionSet, v *model.SteamStatus) graphql.Marshaler {
if v == nil {
return graphql.Null
}
return ec._SteamStatus(ctx, sel, v)
}
func (ec *executionContext) unmarshalOString2string(ctx context.Context, v any) (string, error) { func (ec *executionContext) unmarshalOString2string(ctx context.Context, v any) (string, error) {
res, err := graphql.UnmarshalString(v) res, err := graphql.UnmarshalString(v)
return res, graphql.ErrorOnPath(ctx, err) return res, graphql.ErrorOnPath(ctx, err)

View File

@@ -83,6 +83,19 @@ type SpotifyTrack struct {
Album *SpotifyAlbum `json:"album"` Album *SpotifyAlbum `json:"album"`
} }
type SteamGame struct {
AppID int `json:"appId"`
Name string `json:"name"`
Playtime2Weeks int `json:"playtime2Weeks"`
PlaytimeForever int `json:"playtimeForever"`
HeaderImageURL string `json:"headerImageUrl"`
}
type SteamStatus struct {
Online bool `json:"online"`
RecentGames []*SteamGame `json:"recentGames"`
}
type UpdatePostInput struct { type UpdatePostInput struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`

View File

@@ -450,6 +450,44 @@ func (r *queryResolver) GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, er
return mapGiteaFeed(feed), nil return mapGiteaFeed(feed), nil
} }
// SteamStatus is the resolver for the steamStatus field.
func (r *queryResolver) SteamStatus(ctx context.Context) (*model.SteamStatus, error) {
if r.Store.SteamAPIKey == "" {
return nil, nil
}
if r.Store.SteamFresh() {
return &model.SteamStatus{
Online: r.Store.SteamOnline,
RecentGames: mapSteamGames(r.Store.SteamRecentGames),
}, nil
}
games, err := services.FetchRecentlyPlayedGames(r.Store.SteamAPIKey, r.Store.SteamID)
if err != nil {
return nil, err
}
summary, err := services.FetchPlayerSummary(r.Store.SteamAPIKey, r.Store.SteamID)
if err != nil {
return nil, err
}
online := false
if summary != nil {
online = summary.PersonaState > 0
}
r.Store.SteamRecentGames = games
r.Store.SteamOnline = online
r.Store.SteamFetchedAt = time.Now()
return &model.SteamStatus{
Online: online,
RecentGames: mapSteamGames(games),
}, nil
}
// Me is the resolver for the me field. // Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) { func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
userID, ok := UserIDFromCtx(ctx) userID, ok := UserIDFromCtx(ctx)

View File

@@ -12,6 +12,7 @@ type Query {
spotifyListening: SpotifyPlaying spotifyListening: SpotifyPlaying
spotifyRecent: [SpotifyRecentItem!] spotifyRecent: [SpotifyRecentItem!]
giteaFeed: GiteaFeedItem giteaFeed: GiteaFeedItem
steamStatus: SteamStatus
me: User me: User
} }

View File

@@ -0,0 +1,12 @@
type SteamGame {
appId: Int!
name: String!
playtime2Weeks: Int!
playtimeForever: Int!
headerImageUrl: String!
}
type SteamStatus {
online: Boolean!
recentGames: [SteamGame!]!
}

View File

@@ -0,0 +1,22 @@
package graph
import (
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/services"
)
func mapSteamGames(games []services.SteamRecentGame) []*model.SteamGame {
result := make([]*model.SteamGame, len(games))
for i, g := range games {
result[i] = &model.SteamGame{
AppID: g.AppID,
Name: g.Name,
Playtime2Weeks: g.Playtime2Weeks,
PlaytimeForever: g.PlaytimeForever,
HeaderImageURL: fmt.Sprintf("https://cdn.akamai.steamstatic.com/steam/apps/%d/header.jpg", g.AppID),
}
}
return result
}

View File

@@ -25,6 +25,12 @@ type Store struct {
GiteaPort string GiteaPort string
GiteaFeed *services.GiteaFeedResponse GiteaFeed *services.GiteaFeedResponse
GiteaFeedFetchedAt time.Time GiteaFeedFetchedAt time.Time
SteamAPIKey string
SteamID string
SteamRecentGames []services.SteamRecentGame
SteamOnline bool
SteamFetchedAt time.Time
} }
func (s *Store) GiteaFeedFresh() bool { func (s *Store) GiteaFeedFresh() bool {
@@ -33,3 +39,10 @@ func (s *Store) GiteaFeedFresh() bool {
} }
return time.Since(s.GiteaFeedFetchedAt) < time.Minute return time.Since(s.GiteaFeedFetchedAt) < time.Minute
} }
func (s *Store) SteamFresh() bool {
if s.SteamRecentGames == nil {
return false
}
return time.Since(s.SteamFetchedAt) < 5*time.Minute
}

View File

@@ -74,7 +74,10 @@ func main() {
giteaHost := os.Getenv("GITEA_HOST") giteaHost := os.Getenv("GITEA_HOST")
giteaPort := os.Getenv("GITEA_PORT") giteaPort := os.Getenv("GITEA_PORT")
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, GiteaHost: giteaHost, GiteaPort: giteaPort} steamAPIKey := os.Getenv("STEAM_API_KEY")
steamID := os.Getenv("STEAM_ID")
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, GiteaHost: giteaHost, GiteaPort: giteaPort, SteamAPIKey: steamAPIKey, SteamID: steamID}
protected := r.Group("/", store.AuthMiddlewear) protected := r.Group("/", store.AuthMiddlewear)
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware) admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)

83
backend/services/steam.go Normal file
View File

@@ -0,0 +1,83 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type SteamRecentGame struct {
AppID int `json:"appid"`
Name string `json:"name"`
Playtime2Weeks int `json:"playtime_2weeks"`
PlaytimeForever int `json:"playtime_forever"`
}
type SteamRecentGamesResponse struct {
Response struct {
TotalCount int `json:"total_count"`
Games []SteamRecentGame `json:"games"`
} `json:"response"`
}
type SteamPlayerSummary struct {
PersonaState int `json:"personastate"`
}
type SteamPlayerSummariesResponse struct {
Response struct {
Players []SteamPlayerSummary `json:"players"`
} `json:"response"`
}
func FetchRecentlyPlayedGames(apiKey, steamID string) ([]SteamRecentGame, error) {
url := fmt.Sprintf("https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/?key=%s&steamid=%s&count=3", apiKey, steamID)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result SteamRecentGamesResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result.Response.Games, nil
}
func FetchPlayerSummary(apiKey, steamID string) (*SteamPlayerSummary, error) {
url := fmt.Sprintf("https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=%s&steamids=%s", apiKey, steamID)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result SteamPlayerSummariesResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
if len(result.Response.Players) == 0 {
return nil, nil
}
return &result.Response.Players[0], nil
}

View File

@@ -14,6 +14,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
const spotifyRecent = ref([]); const spotifyRecent = ref([]);
const rowingSessions = ref([]); const rowingSessions = ref([]);
const gitFeed = ref(null); const gitFeed = ref(null);
const steamStatus = ref(null);
const radioLive = ref(false); const radioLive = ref(false);
async function fetchAll() { async function fetchAll() {
@@ -27,6 +28,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
spotifyRecent { track { name album { name images { url } } artists { name } } playedAt } spotifyRecent { track { name album { name images { url } } artists { name } } playedAt }
rowingSessions { id date time distance timePer500m calories } rowingSessions { id date time distance timePer500m calories }
giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt } giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt }
steamStatus { online recentGames { appId name playtime2Weeks playtimeForever headerImageUrl } }
me { id username admin } me { id username admin }
} }
`), `),
@@ -38,6 +40,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
spotifyRecent.value = data.spotifyRecent || []; spotifyRecent.value = data.spotifyRecent || [];
rowingSessions.value = data.rowingSessions; rowingSessions.value = data.rowingSessions;
gitFeed.value = data.giteaFeed || null; gitFeed.value = data.giteaFeed || null;
steamStatus.value = data.steamStatus || null;
me.value = data.me || null; me.value = data.me || null;
loaded.value = true; loaded.value = true;
} catch (err) { } catch (err) {
@@ -67,6 +70,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
spotifyRecent, spotifyRecent,
rowingSessions, rowingSessions,
gitFeed, gitFeed,
steamStatus,
radioLive, radioLive,
fetchAll, fetchAll,
fetchRadioStatus, fetchRadioStatus,

42
vue/src/stores/steam.js Normal file
View File

@@ -0,0 +1,42 @@
import { defineStore } from "pinia";
import { ref, watch } from "vue";
import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
export const useSteamStore = defineStore("steam", () => {
const steamStatus = ref({ online: false, recentGames: [] });
const homeData = useHomeDataStore();
watch(
() => homeData.steamStatus,
(newStatus) => {
if (newStatus) {
steamStatus.value = newStatus;
}
},
{ immediate: true },
);
async function fetchSteam() {
try {
const data = await gql(`
query {
steamStatus {
online
recentGames { appId name playtime2Weeks playtimeForever headerImageUrl }
}
}
`);
if (data.steamStatus) {
steamStatus.value = data.steamStatus;
}
} catch (err) {
console.error("Failed to fetch Steam status", err);
}
}
return {
steamStatus,
fetchSteam,
};
});

View File

@@ -20,6 +20,7 @@ import Favorites from "./Favorites.vue";
// import Gym from "./Gym.vue"; // import Gym from "./Gym.vue";
import Gym2 from "./Gym2.vue"; import Gym2 from "./Gym2.vue";
import Consumption from "./Consumption.vue"; import Consumption from "./Consumption.vue";
import Steam from "./Steam.vue";
</script> </script>
<template> <template>
@@ -30,6 +31,7 @@ import Consumption from "./Consumption.vue";
class="flex-1 flex flex-col min-h-0 background-children border-children gap-2" class="flex-1 flex flex-col min-h-0 background-children border-children gap-2"
> >
<Chat class="flex-1 min-h-0" /> <Chat class="flex-1 min-h-0" />
<Steam />
</div> </div>
<div class="sidebar-image"> <div class="sidebar-image">
<Miku class="border-tertiary border bg-bg_secondary h-60" /> <Miku class="border-tertiary border bg-bg_secondary h-60" />

View File

@@ -0,0 +1,66 @@
<script setup>
import { onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import { useSteamStore } from "@/stores/steam";
import { useHomeDataStore } from "@/stores/homeData";
import Header from "@/components/text/Header.vue";
const steamStore = useSteamStore();
const { steamStatus } = storeToRefs(steamStore);
const homeData = useHomeDataStore();
const { loaded } = storeToRefs(homeData);
let refreshInterval;
onMounted(() => {
refreshInterval = setInterval(() => steamStore.fetchSteam(), 5 * 60 * 1000);
});
onUnmounted(() => clearInterval(refreshInterval));
function formatHours(minutes) {
const hrs = (minutes / 60).toFixed(1);
return `${hrs}h`;
}
</script>
<template>
<div class="flex flex-col min-h-0 overflow-hidden">
<Header class="text-left">
<span class="flex items-center gap-2">
Steam
<span
class="inline-block w-2 h-2 rounded-full"
:class="steamStatus.online ? 'bg-green-500' : 'bg-gray-400'"
:title="steamStatus.online ? 'Online' : 'Offline'"
/>
</span>
</Header>
<div v-if="!loaded" class="p-2 text-sm">Loading...</div>
<div
v-else-if="steamStatus.recentGames.length"
class="flex-1 overflow-y-auto flex flex-col gap-2 p-1"
>
<div
v-for="game in steamStatus.recentGames"
:key="game.appId"
class="flex flex-col"
>
<img
:src="game.headerImageUrl"
:alt="game.name"
class="w-full object-cover"
loading="lazy"
/>
<div class="px-1 py-0.5 text-xs">
<p class="font-bold truncate">{{ game.name }}</p>
<p class="text-tertiary">
{{ formatHours(game.playtime2Weeks) }} last 2 weeks
</p>
</div>
</div>
</div>
<div v-else class="p-2 text-sm">No recent games.</div>
</div>
</template>