mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
⭐ 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:
@@ -88,7 +88,6 @@ export namespace FriendApi {
|
||||
return c.json({
|
||||
data: friend
|
||||
})
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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: () => { },
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
91
packages/functions/src/api/team.ts
Normal file
91
packages/functions/src/api/team.ts
Normal 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
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user