feat(core): Implement Steam library sync with metadata extraction and image processing (#278)

## Description
<!-- Briefly describe the purpose and scope of your changes -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added AWS queue infrastructure and SQS handler for processing Steam
game libraries and images.
- Introduced event-driven handling for new credentials and game
additions, including image uploads to S3.
- Added client functions to fetch Steam user libraries, friends lists,
app info, and related images.
- Added new database columns and schema updates to track game
acquisition, playtime, and family sharing.
  - Added utility function for chunking arrays.
- Added new event notifications for library queue processing and game
creation.
  - Added new lookup functions for categories and teams by slug.
- Introduced a new Team API with endpoints to list and fetch teams by
slug.
  - Added a new Steam library page displaying game images.

- **Enhancements**
  - Improved game creation with event notifications and upsert logic.
  - Enhanced category and team retrieval with new lookup functions.
  - Renamed and refined image categories for clearer classification.
  - Expanded dependencies for image processing and AWS SDK integration.
- Improved image processing utilities with caching, ranking, and
metadata extraction.
  - Refined Steam client utilities for concurrency and error handling.

- **Bug Fixes**
- Fixed event publishing timing and removed deprecated credential
retrieval methods.

- **Chores**
- Updated infrastructure configurations with increased timeouts, memory,
and resource linking.
- Added new dependencies for image processing, caching, and AWS SDK
clients.
  - Refined internal code structure and imports for clarity.
  - Removed Steam provider and related UI components from the frontend.
- Disabled authentication providers and Steam-related routes in the
frontend.
  - Updated API fetch handler to accept environment bindings.

- **Refactor**
- Simplified query result handling and renamed functions for better
clarity.
- Removed outdated event handler in favor of consolidated event
subscriber.
- Consolidated and simplified database relationships and permission
queries.

- **Tests**
  - No explicit test changes included in this release.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wanjohi
2025-05-17 00:51:18 +03:00
committed by GitHub
parent cc2065299d
commit e1a903a7c9
82 changed files with 7819 additions and 1002 deletions

View File

@@ -88,7 +88,6 @@ export namespace FriendApi {
return c.json({
data: friend
})
}
)
}

View File

@@ -4,8 +4,8 @@ import { describeRoute } from "hono-openapi";
import { Game } from "@nestri/core/game/index";
import { Examples } from "@nestri/core/examples";
import { Library } from "@nestri/core/library/index";
import { ErrorResponses, notPublic, Result, validator } from "./utils";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
import { ErrorResponses, notPublic, Result, validator } from "./utils";
export namespace GameApi {
export const route = new Hono()
@@ -14,20 +14,20 @@ export namespace GameApi {
describeRoute({
tags: ["Game"],
summary: "List games",
description: "List all the games on a user's library",
description: "List all the games on this user's library",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Game.Info.array().openapi({
description: "All games",
description: "All games in the library",
example: [Examples.Game]
})
),
},
},
description: "Game details"
description: "All games in the library"
},
400: ErrorResponses[400],
404: ErrorResponses[404],

View File

@@ -1,11 +1,11 @@
import "zod-openapi/extend";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { GameApi } from "./game";
import { SteamApi } from "./steam";
import { auth } from "./utils/auth";
import { FriendApi } from "./friend";
import { logger } from "hono/logger";
import { type Env, Hono } from "hono";
import { Realtime } from "./realtime";
import { AccountApi } from "./account";
import { openAPISpecs } from "hono-openapi";
@@ -98,8 +98,8 @@ export default {
port: 3001,
idleTimeout: 255,
webSocketHandler: Realtime.webSocketHandler,
fetch: (req: Request) =>
app.fetch(req, undefined, {
fetch: (req: Request,env: Env) =>
app.fetch(req, env, {
waitUntil: (fn) => fn,
passThroughOnException: () => { },
}),

View File

@@ -1,6 +1,11 @@
import { setup } from "actor-core";
import chatRoom from "./actor-core";
import { createRouter } from "@actor-core/bun";
import {
FileSystemGlobalState,
FileSystemActorDriver,
FileSystemManagerDriver,
} from "@actor-core/file-system";
export namespace Realtime {
const app = setup({
@@ -8,7 +13,15 @@ export namespace Realtime {
basePath: "/realtime"
});
const realtimeRouter = createRouter(app);
const fsState = new FileSystemGlobalState("/tmp");
const realtimeRouter = createRouter(app, {
topology: "standalone",
drivers: {
manager: new FileSystemManagerDriver(app, fsState),
actor: new FileSystemActorDriver(fsState),
}
});
export const route = realtimeRouter.router;
export const webSocketHandler = realtimeRouter.webSocketHandler;

View File

@@ -1,17 +1,24 @@
import { z } from "zod";
import { Hono } from "hono";
import crypto from 'crypto';
import { Resource } from "sst";
import { streamSSE } from "hono/streaming";
import { Actor } from "@nestri/core/actor";
import SteamCommunity from "steamcommunity";
import { describeRoute } from "hono-openapi";
import { Steam } from "@nestri/core/steam/index";
import { Team } from "@nestri/core/team/index";
import { Examples } from "@nestri/core/examples";
import { Steam } from "@nestri/core/steam/index";
import { Member } from "@nestri/core/member/index";
import { Client } from "@nestri/core/client/index";
import { Library } from "@nestri/core/library/index";
import { chunkArray } from "@nestri/core/utils/helper";
import { ErrorResponses, validator, Result } from "./utils";
import { Credentials } from "@nestri/core/credentials/index";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
import { LoginSession, EAuthTokenPlatformType } from "steam-session";
import type CSteamUser from "steamcommunity/classes/CSteamUser";
const sqs = new SQSClient({});
export namespace SteamApi {
export const route = new Hono()
@@ -157,15 +164,7 @@ export namespace SteamApi {
const community = new SteamCommunity();
community.setCookies(cookies);
const user = await new Promise((res, rej) => {
community.getSteamUser(session.steamID, async (error, user) => {
if (!error) {
res(user)
} else {
rej(error)
}
})
}) as CSteamUser
const user = await Client.getUserInfo({ steamID, cookies })
const wasAdded =
await Steam.create({
@@ -187,28 +186,58 @@ export namespace SteamApi {
})
// Does not matter if the user is already there or has just been created, just store the credentials
await Credentials.create({ refreshToken, id: steamID, username })
await Credentials.create({ refreshToken, steamID, username })
let teamID: string | undefined
if (wasAdded) {
const rawFirst = (user.name ?? username).trim().split(/\s+/)[0] ?? username;
const firstName = rawFirst
.charAt(0) // first character
.toUpperCase() // make it uppercase
+ rawFirst
.slice(1) // rest of the string
.toLowerCase();
if (!!wasAdded) {
// create a team
const teamID = await Team.create({
teamID = await Team.create({
slug: username,
name: `${user.name.split(" ")[0]}'s Team`,
name: firstName,
ownerID: currentUser.userID,
})
// Add us as a member
await Actor.provide(
"system",
{ teamID },
async () => {
async () =>
await Member.create({
role: "adult",
userID: currentUser.userID,
steamID
})
})
)
} else {
// Update the owner of the Steam account
await Steam.updateOwner({ userID: currentUser.userID, steamID })
const t = await Actor.provide(
"user",
currentUser,
async () => {
// Get the team associated with this username
const team = await Team.fromSlug(username);
// This should never happen
if (!team) throw Error(`Is Nestri okay???, we could not find the team with this slug ${username}`)
teamID = team.id
return team.id
}
)
console.log("t",t)
console.log("teamID",teamID)
}
await stream.writeSSE({
@@ -216,13 +245,71 @@ export namespace SteamApi {
data: JSON.stringify({ username })
})
//TODO: Get game library
// Get game library in the background
c.executionCtx.waitUntil((async () => {
const games = await Client.getUserLibrary(accessToken);
await stream.close()
// Get a batch of 5 games each
const apps = games?.response?.apps || [];
if (apps.length === 0) {
console.info("[SteamApi] Is Steam okay? No games returned for user:", { steamID });
return
}
resolve()
const chunkedGames = chunkArray(apps, 5);
// Get the batches to the queue
const processQueue = chunkedGames.map(async (chunk) => {
const myGames = chunk.map(i => {
return {
appID: i.appid,
totalPlaytime: i.rt_playtime,
isFamilyShareable: i.exclude_reason === 0,
lastPlayed: new Date(i.rt_last_played * 1000),
timeAcquired: new Date(i.rt_time_acquired * 1000),
isFamilyShared: !i.owner_steamids.includes(steamID) && i.exclude_reason === 0,
}
})
if (teamID) {
const deduplicationId = crypto
.createHash('md5')
.update(`${teamID}_${chunk.map(g => g.appid).join(',')}`)
.digest('hex');
await Actor.provide(
"member",
{
teamID,
steamID,
userID: currentUser.userID
},
async () => {
const payload = await Library.Events.Queue.create(myGames);
await sqs.send(
new SendMessageCommand({
MessageGroupId: teamID,
QueueUrl: Resource.LibraryQueue.url,
MessageBody: JSON.stringify(payload),
MessageDeduplicationId: deduplicationId,
})
)
}
)
}
})
const settled = await Promise.allSettled(processQueue)
settled
.filter(r => r.status === "rejected")
.forEach(r => console.error("[LibraryQueue] enqueue failed:", (r as PromiseRejectedResult).reason));
})())
await stream.close();
resolve();
})
})
})
}

View File

@@ -0,0 +1,91 @@
import { z } from "zod"
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { Team } from "@nestri/core/team/index";
import { Examples } from "@nestri/core/examples";
import { ErrorResponses, Result, validator } from "./utils";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
export namespace TeamApi {
export const route = new Hono()
.get("/",
describeRoute({
tags: ["Team"],
summary: "List user teams",
description: "List the current user's team details",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Team.Info.array().openapi({
description: "All team information",
example: [Examples.Team]
})
),
},
},
description: "All team details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
async (c) =>
c.json({
data: await Team.list()
})
)
.get("/:slug",
describeRoute({
tags: ["Team"],
summary: "Get team by slug",
description: "Get the current user's team details, by its slug",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Team.Info.openapi({
description: "Team details",
example: Examples.Team
})
),
},
},
description: "Team details"
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429],
}
}),
validator(
"param",
z.object({
slug: z.string().openapi({
description: "SLug of the team to get",
example: Examples.Team.slug,
}),
}),
),
async (c) => {
const teamSlug = c.req.valid("param").slug
const team = await Team.fromSlug(teamSlug)
if (!team) {
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`Team ${teamSlug} not found`
)
}
return c.json({
data: team
})
}
)
}