⭐ 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 -->
@@ -3,6 +3,7 @@ import { auth } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
import { LibraryQueue } from "./steam";
|
||||
import { secret, steamEncryptionKey } from "./secret";
|
||||
|
||||
export const apiService = new sst.aws.Service("Api", {
|
||||
@@ -13,6 +14,7 @@ export const apiService = new sst.aws.Service("Api", {
|
||||
bus,
|
||||
auth,
|
||||
postgres,
|
||||
LibraryQueue,
|
||||
steamEncryptionKey,
|
||||
secret.PolarSecret,
|
||||
secret.PolarWebhookSecret,
|
||||
|
||||
11
infra/bus.ts
@@ -1,19 +1,22 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { storage } from "./storage";
|
||||
// import { email } from "./email";
|
||||
import { allSecrets } from "./secret";
|
||||
import { postgres } from "./postgres";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
export const bus = new sst.aws.Bus("Bus");
|
||||
|
||||
bus.subscribe("Event", {
|
||||
vpc,
|
||||
handler: "./packages/functions/src/event/event.handler",
|
||||
handler: "packages/functions/src/events/index.handler",
|
||||
link: [
|
||||
// email,
|
||||
postgres,
|
||||
...allSecrets
|
||||
storage,
|
||||
steamEncryptionKey
|
||||
],
|
||||
timeout: "5 minutes",
|
||||
timeout: "10 minutes",
|
||||
memory: "3002 MB",// For faster processing of large(r) images
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
new sst.x.DevCommand("Steam", {
|
||||
dev: {
|
||||
command: "bun dev",
|
||||
directory: "packages/steam",
|
||||
autostart: true,
|
||||
},
|
||||
import { vpc } from "./vpc";
|
||||
import { postgres } from "./postgres";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
export const LibraryQueue = new sst.aws.Queue("LibraryQueue", {
|
||||
fifo: true,
|
||||
visibilityTimeout: "10 minutes",
|
||||
});
|
||||
|
||||
LibraryQueue.subscribe({
|
||||
vpc,
|
||||
timeout: "10 minutes",
|
||||
memory: "3002 MB",
|
||||
handler: "packages/functions/src/queues/library.handler",
|
||||
link: [
|
||||
postgres,
|
||||
steamEncryptionKey
|
||||
],
|
||||
});
|
||||
4
packages/core/migrations/0016_melted_johnny_storm.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "time_acquired" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "last_played" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "total_playtime" integer NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "is_family_shared" boolean NOT NULL;
|
||||
4
packages/core/migrations/0017_zippy_nico_minoru.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."image_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'backdrop');--> statement-breakpoint
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE "public"."image_type" USING "type"::"public"."image_type";
|
||||
4
packages/core/migrations/0018_solid_enchantress.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."image_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'banner', 'poster', 'boxArt', 'screenshot', 'backdrop');--> statement-breakpoint
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE "public"."image_type" USING "type"::"public"."image_type";
|
||||
19
packages/core/migrations/0019_charming_namorita.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Unfortunately in current drizzle-kit version we can't automatically get name for primary key.
|
||||
We are working on making it available!
|
||||
|
||||
Meanwhile you can:
|
||||
1. Check pk name in your database, by running
|
||||
SELECT constraint_name FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'steam_account_credentials'
|
||||
AND constraint_type = 'PRIMARY KEY';
|
||||
2. Uncomment code below and paste pk name manually
|
||||
|
||||
Hope to release this update as soon as possible
|
||||
*/
|
||||
|
||||
-- ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "<constraint_name>";--> statement-breakpoint
|
||||
ALTER TABLE "images" ALTER COLUMN "source_url" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_id_pk" PRIMARY KEY("steam_id","id");--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD COLUMN "id" char(30) NOT NULL;
|
||||
1290
packages/core/migrations/meta/0016_snapshot.json
Normal file
1290
packages/core/migrations/meta/0017_snapshot.json
Normal file
1290
packages/core/migrations/meta/0018_snapshot.json
Normal file
1304
packages/core/migrations/meta/0019_snapshot.json
Normal file
@@ -113,6 +113,34 @@
|
||||
"when": 1746928882281,
|
||||
"tag": "0015_handy_giant_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1747032794033,
|
||||
"tag": "0016_melted_johnny_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1747034424687,
|
||||
"tag": "0017_zippy_nico_minoru",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1747073173196,
|
||||
"tag": "0018_solid_enchantress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1747202158003,
|
||||
"tag": "0019_charming_namorita",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"mqtt": "^5.10.3",
|
||||
@@ -32,7 +34,14 @@
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { eq, isNull, or, and } from "drizzle-orm";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum } from "./base-game.sql";
|
||||
|
||||
export namespace BaseGame {
|
||||
@@ -56,32 +59,53 @@ export namespace BaseGame {
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_game.added",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const results = await tx
|
||||
await tx
|
||||
.insert(baseGamesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: baseGamesTable.id,
|
||||
set: {
|
||||
timeDeleted: null
|
||||
}
|
||||
})
|
||||
|
||||
await afterTx(async () => {
|
||||
await bus.publish(Resource.Bus, Events.New, { appID: input.id })
|
||||
})
|
||||
|
||||
return input.id
|
||||
})
|
||||
)
|
||||
|
||||
export const fromID = fn(
|
||||
Info.shape.id,
|
||||
(id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(baseGamesTable)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(baseGamesTable.slug, input.slug),
|
||||
eq(baseGamesTable.id, input.id),
|
||||
),
|
||||
eq(baseGamesTable.id, id),
|
||||
isNull(baseGamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
if (results.length > 0) return null
|
||||
|
||||
await tx
|
||||
.insert(baseGamesTable)
|
||||
.values(input)
|
||||
|
||||
return input.id
|
||||
})
|
||||
.limit(1)
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { categoriesTable } from "./categories.sql";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
|
||||
export namespace Categories {
|
||||
@@ -52,20 +52,21 @@ export namespace Categories {
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const results =
|
||||
await tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (results.length > 0) return null
|
||||
if (result) return result.slug
|
||||
|
||||
await tx
|
||||
.insert(categoriesTable)
|
||||
@@ -79,6 +80,26 @@ export namespace Categories {
|
||||
})
|
||||
)
|
||||
|
||||
export const get = fn(
|
||||
InputInfo.pick({ slug: true, type: true }),
|
||||
(input) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => serialize(rows))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof categoriesTable.$inferSelect[],
|
||||
): z.infer<typeof Info> {
|
||||
|
||||
240
packages/core/src/client/index.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type {
|
||||
AppInfo,
|
||||
GameTagsResponse,
|
||||
SteamApiResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
ImageInfo,
|
||||
ImageType,
|
||||
Shot
|
||||
} from "./types";
|
||||
import { z } from "zod";
|
||||
import pLimit from 'p-limit';
|
||||
import SteamID from "steamid";
|
||||
import { fn } from "../utils";
|
||||
import { Utils } from "./utils";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { Credentials } from "../credentials";
|
||||
import type CSteamUser from "steamcommunity/classes/CSteamUser";
|
||||
|
||||
const requestLimit = pLimit(10); // max concurrent requests
|
||||
|
||||
export namespace Client {
|
||||
export const getUserLibrary = fn(
|
||||
Credentials.Info.shape.accessToken,
|
||||
async (accessToken) =>
|
||||
await Utils.fetchApi<SteamApiResponse>(`https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?access_token=${accessToken}&family_groupid=0&include_excluded=true&include_free=true&include_non_games=false&include_own=true`)
|
||||
)
|
||||
|
||||
export const getFriendsList = fn(
|
||||
Credentials.Info.shape.cookies,
|
||||
async (cookies): Promise<CSteamUser[]> => {
|
||||
const community = new SteamCommunity();
|
||||
community.setCookies(cookies);
|
||||
|
||||
const allFriends = await new Promise<Record<string, any>>((resolve, reject) => {
|
||||
community.getFriendsList((err, friends) => {
|
||||
if (err) {
|
||||
return reject(new Error(`Could not get friends list: ${err.message}`));
|
||||
}
|
||||
resolve(friends);
|
||||
});
|
||||
});
|
||||
|
||||
const friendIds = Object.keys(allFriends);
|
||||
|
||||
const userPromises: Promise<CSteamUser>[] = friendIds.map(id =>
|
||||
requestLimit(() => new Promise<CSteamUser>((resolve, reject) => {
|
||||
const sid = new SteamID(id);
|
||||
community.getSteamUser(sid, (err, user) => {
|
||||
if (err) {
|
||||
return reject(new Error(`Could not get steam user info for ${id}: ${err.message}`));
|
||||
}
|
||||
resolve(user);
|
||||
});
|
||||
}))
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(userPromises)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getFriendsList] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<CSteamUser>).value);
|
||||
}
|
||||
);
|
||||
|
||||
export const getUserInfo = fn(
|
||||
Credentials.Info.pick({ cookies: true, steamID: true }),
|
||||
async (input) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const community = new SteamCommunity()
|
||||
community.setCookies(input.cookies);
|
||||
const steamID = new SteamID(input.steamID);
|
||||
community.getSteamUser(steamID, async (err, user) => {
|
||||
if (err) {
|
||||
reject(`Could not get steam user info: ${err.message}`)
|
||||
} else {
|
||||
resolve(user)
|
||||
}
|
||||
})
|
||||
}) as Promise<CSteamUser>
|
||||
)
|
||||
|
||||
export const getAppInfo = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [infoData, tagsData, details] = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<GameTagsResponse>("https://store.steampowered.com/actions/ajaxgetstoretags"),
|
||||
Utils.fetchApi<GameDetailsResponse>(
|
||||
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
|
||||
),
|
||||
]);
|
||||
|
||||
const tags = tagsData.tags;
|
||||
const game = infoData.data[appid];
|
||||
// Guard against an empty string - When there are no genres, Steam returns an empty string
|
||||
const genres = details.strGenres ? Utils.parseGenres(details.strGenres) : [];
|
||||
|
||||
const controllerTag = game.common.controller_support ?
|
||||
Utils.createTag(`${Utils.capitalise(game.common.controller_support)} Controller Support`) :
|
||||
Utils.createTag(`Unknown Controller Support`)
|
||||
|
||||
const compatibilityTag = Utils.createTag(`${Utils.capitalise(Utils.compatibilityType(game.common.steam_deck_compatibility?.category))} Compatibility`)
|
||||
|
||||
const controller = (game.common.controller_support === "partial" || game.common.controller_support === "full") ? game.common.controller_support : "unknown";
|
||||
const appInfo: AppInfo = {
|
||||
genres,
|
||||
gameid: game.appid,
|
||||
name: game.common.name.trim(),
|
||||
size: Utils.getPublicDepotSizes(game.depots!),
|
||||
slug: Utils.createSlug(game.common.name.trim()),
|
||||
description: Utils.cleanDescription(details.strDescription),
|
||||
controllerSupport: controller,
|
||||
releaseDate: new Date(Number(game.common.steam_release_date) * 1000),
|
||||
primaryGenre: (!!game?.common.genres && !!details.strGenres) ? Utils.getPrimaryGenre(
|
||||
genres,
|
||||
game.common.genres!,
|
||||
game.common.primary_genre!
|
||||
) : null,
|
||||
developers: game.common.associations ?
|
||||
Array.from(
|
||||
Utils.getAssociationsByTypeWithSlug(
|
||||
game.common.associations!,
|
||||
"developer"
|
||||
)
|
||||
) : [],
|
||||
publishers: game.common.associations ?
|
||||
Array.from(
|
||||
Utils.getAssociationsByTypeWithSlug(
|
||||
game.common.associations!,
|
||||
"publisher"
|
||||
)
|
||||
) : [],
|
||||
compatibility: Utils.compatibilityType(game.common.steam_deck_compatibility?.category),
|
||||
tags: [
|
||||
...(game?.common.store_tags ?
|
||||
Utils.mapGameTags(
|
||||
tags,
|
||||
game.common.store_tags!,
|
||||
) : []),
|
||||
controllerTag,
|
||||
compatibilityTag
|
||||
],
|
||||
score: Utils.getRating(
|
||||
details.ReviewSummary.cRecommendationsPositive,
|
||||
details.ReviewSummary.cRecommendationsNegative
|
||||
),
|
||||
};
|
||||
|
||||
return appInfo
|
||||
}
|
||||
)
|
||||
|
||||
export const getImages = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [appData, details] = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<GameDetailsResponse>(
|
||||
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
|
||||
),
|
||||
]);
|
||||
|
||||
const game = appData.data[appid]?.common;
|
||||
if (!game) throw new Error('Game info missing');
|
||||
|
||||
// 2. Prepare URLs
|
||||
const screenshotUrls = Utils.getScreenshotUrls(details.rgScreenshots || []);
|
||||
const assetUrls = Utils.getAssetUrls(game.library_assets_full, appid, game.header_image.english);
|
||||
const iconUrl = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
|
||||
|
||||
//2.5 Get the backdrop buffer and use it to get the best screenshot
|
||||
const baselineBuffer = await Utils.fetchBuffer(assetUrls.backdrop);
|
||||
|
||||
// 3. Download screenshot buffers in parallel
|
||||
const shots: Shot[] = await Promise.all(
|
||||
screenshotUrls.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
|
||||
);
|
||||
|
||||
// 4. Score screenshots (or pick single)
|
||||
const scores =
|
||||
shots.length === 1
|
||||
? [{ url: shots[0].url, score: 0 }]
|
||||
: (await Utils.rankScreenshots(baselineBuffer, shots, {
|
||||
threshold: 0.08,
|
||||
}))
|
||||
|
||||
// Build url->rank map
|
||||
const rankMap = new Map<string, number>();
|
||||
scores.forEach((s, i) => rankMap.set(s.url, i));
|
||||
|
||||
// 5. Create tasks for all images
|
||||
const tasks: Array<Promise<ImageInfo>> = [];
|
||||
|
||||
// 5a. Screenshots and heroArt metadata (top 4)
|
||||
for (const { url, buffer } of shots) {
|
||||
const rank = rankMap.get(url);
|
||||
if (rank === undefined || rank >= 4) continue;
|
||||
const type: ImageType = rank === 0 ? 'heroArt' : 'screenshot';
|
||||
tasks.push(
|
||||
Utils.getImageMetadata(buffer).then(meta => ({ ...meta, sourceUrl: url, position: type == "screenshot" ? rank - 1 : rank, type } as ImageInfo))
|
||||
);
|
||||
}
|
||||
|
||||
// 5b. Asset images
|
||||
for (const [type, url] of Object.entries({ ...assetUrls, icon: iconUrl })) {
|
||||
if (!url || type === "backdrop") continue;
|
||||
tasks.push(
|
||||
Utils.fetchBuffer(url)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: url, type: type as ImageType } as ImageInfo))
|
||||
);
|
||||
}
|
||||
|
||||
// 5c. Backdrop
|
||||
tasks.push(
|
||||
Utils.getImageMetadata(baselineBuffer)
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: assetUrls.backdrop, type: "backdrop" as const } as ImageInfo))
|
||||
)
|
||||
|
||||
// 5d. Box art
|
||||
tasks.push(
|
||||
Utils.createBoxArtBuffer(game.library_assets_full, appid)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(tasks)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getImages] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
// 6. Await all and return
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<ImageInfo>).value)
|
||||
}
|
||||
)
|
||||
}
|
||||
344
packages/core/src/client/types.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
export interface SteamApp {
|
||||
/** Steam application ID */
|
||||
appid: number;
|
||||
|
||||
/** Array of Steam IDs that own this app */
|
||||
owner_steamids: string[];
|
||||
|
||||
/** Name of the game/application */
|
||||
name: string;
|
||||
|
||||
/** Filename of the game's capsule image */
|
||||
capsule_filename: string;
|
||||
|
||||
/** Hash value for the game's icon */
|
||||
img_icon_hash: string;
|
||||
|
||||
/** Reason code for exclusion (0 indicates no exclusion) */
|
||||
exclude_reason: number;
|
||||
|
||||
/** Unix timestamp when the app was acquired */
|
||||
rt_time_acquired: number;
|
||||
|
||||
/** Unix timestamp when the app was last played */
|
||||
rt_last_played: number;
|
||||
|
||||
/** Total playtime in seconds */
|
||||
rt_playtime: number;
|
||||
|
||||
/** Type identifier for the app (1 = game) */
|
||||
app_type: number;
|
||||
|
||||
/** Array of content descriptor IDs */
|
||||
content_descriptors?: number[];
|
||||
}
|
||||
|
||||
export interface SteamApiResponse {
|
||||
response: {
|
||||
apps: SteamApp[];
|
||||
owner_steamid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamAppDataResponse {
|
||||
data: Record<string, SteamAppEntry>;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SteamAppEntry {
|
||||
_change_number: number;
|
||||
_missing_token: boolean;
|
||||
_sha: string;
|
||||
_size: number;
|
||||
appid: string;
|
||||
common: CommonData;
|
||||
config: AppConfig;
|
||||
depots: AppDepots;
|
||||
extended: AppExtended;
|
||||
ufs: UFSData;
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
associations: Record<string, { name: string; type: string }>;
|
||||
category: Record<string, string>;
|
||||
clienticon: string;
|
||||
clienttga: string;
|
||||
community_hub_visible: string;
|
||||
community_visible_stats: string;
|
||||
content_descriptors: Record<string, string>;
|
||||
controller_support?: string;
|
||||
controllertagwizard: string;
|
||||
gameid: string;
|
||||
genres: Record<string, string>;
|
||||
header_image: Record<string, string>;
|
||||
icon: string;
|
||||
languages: Record<string, string>;
|
||||
library_assets: LibraryAssets;
|
||||
library_assets_full: LibraryAssetsFull;
|
||||
metacritic_fullurl: string;
|
||||
metacritic_name: string;
|
||||
metacritic_score: string;
|
||||
name: string;
|
||||
name_localized: Partial<Record<LanguageCode, string>>;
|
||||
osarch: string;
|
||||
osextended: string;
|
||||
oslist: string;
|
||||
primary_genre: string;
|
||||
releasestate: string;
|
||||
review_percentage: string;
|
||||
review_score: string;
|
||||
small_capsule: Record<string, string>;
|
||||
steam_deck_compatibility: SteamDeckCompatibility;
|
||||
steam_release_date: string;
|
||||
store_asset_mtime: string;
|
||||
store_tags: Record<string, string>;
|
||||
supported_languages: Record<
|
||||
string,
|
||||
{
|
||||
full_audio?: string;
|
||||
subtitles?: string;
|
||||
supported?: string;
|
||||
}
|
||||
>;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssets {
|
||||
library_capsule: string;
|
||||
library_header: string;
|
||||
library_hero: string;
|
||||
library_logo: string;
|
||||
logo_position: LogoPosition;
|
||||
}
|
||||
|
||||
export interface LogoPosition {
|
||||
height_pct: string;
|
||||
pinned_position: string;
|
||||
width_pct: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssetsFull {
|
||||
library_capsule: ImageSet;
|
||||
library_header: ImageSet;
|
||||
library_hero: ImageSet;
|
||||
library_logo: ImageSet & { logo_position: LogoPosition };
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ImageSet {
|
||||
image: Record<string, string>;
|
||||
image2x?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SteamDeckCompatibility {
|
||||
category: string;
|
||||
configuration: Record<string, string>;
|
||||
test_timestamp: string;
|
||||
tested_build_id: string;
|
||||
tests: Record<string, { display: string; token: string }>;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
installdir: string;
|
||||
launch: Record<
|
||||
string,
|
||||
{
|
||||
executable: string;
|
||||
type: string;
|
||||
arguments?: string;
|
||||
description?: string;
|
||||
description_loc?: Record<string, string>;
|
||||
config?: {
|
||||
betakey: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
steamcontrollertemplateindex: string;
|
||||
steamdecktouchscreen: string;
|
||||
}
|
||||
|
||||
export interface AppDepots {
|
||||
branches: AppDepotBranches;
|
||||
privatebranches: Record<string, AppDepotBranches>;
|
||||
[depotId: string]: DepotEntry
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
}
|
||||
|
||||
|
||||
export interface DepotEntry {
|
||||
manifests: {
|
||||
public: {
|
||||
download: string;
|
||||
gid: string;
|
||||
size: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppDepotBranches {
|
||||
[branchName: string]: {
|
||||
buildid: string;
|
||||
timeupdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppExtended {
|
||||
additional_dependencies: Array<{
|
||||
dest_os: string;
|
||||
h264: string;
|
||||
src_os: string;
|
||||
}>;
|
||||
developer: string;
|
||||
dlcavailableonstore: string;
|
||||
homepage: string;
|
||||
listofdlc: string;
|
||||
publisher: string;
|
||||
}
|
||||
|
||||
export interface UFSData {
|
||||
maxnumfiles: string;
|
||||
quota: string;
|
||||
savefiles: Array<{
|
||||
path: string;
|
||||
pattern: string;
|
||||
recursive: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type LanguageCode =
|
||||
| "english"
|
||||
| "french"
|
||||
| "german"
|
||||
| "italian"
|
||||
| "japanese"
|
||||
| "koreana"
|
||||
| "polish"
|
||||
| "russian"
|
||||
| "schinese"
|
||||
| "tchinese"
|
||||
| "brazilian"
|
||||
| "spanish";
|
||||
|
||||
export interface Screenshot {
|
||||
appid: number;
|
||||
id: number;
|
||||
filename: string;
|
||||
all_ages: string;
|
||||
normalized_name: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
strDisplayName: string;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
strReviewSummary: string;
|
||||
cReviews: number;
|
||||
cRecommendationsPositive: number;
|
||||
cRecommendationsNegative: number;
|
||||
nReviewScore: number;
|
||||
}
|
||||
|
||||
export interface GameDetailsResponse {
|
||||
strReleaseDate: string;
|
||||
strDescription: string;
|
||||
rgScreenshots: Screenshot[];
|
||||
rgCategories: Category[];
|
||||
strGenres?: string;
|
||||
strFullDescription: string;
|
||||
strMicroTrailerURL: string;
|
||||
ReviewSummary: ReviewSummary;
|
||||
}
|
||||
|
||||
// Define the TypeScript interfaces
|
||||
export interface Tag {
|
||||
tagid: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TagWithSlug {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface StoreTags {
|
||||
[key: string]: string; // Index signature for numeric string keys to tag ID strings
|
||||
}
|
||||
|
||||
|
||||
export interface GameTagsResponse {
|
||||
tags: Tag[];
|
||||
success: number;
|
||||
rwgrsn: number;
|
||||
}
|
||||
|
||||
export type GenreType = {
|
||||
type: 'genre';
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
slug: string;
|
||||
score: number;
|
||||
gameid: string;
|
||||
releaseDate: Date;
|
||||
description: string;
|
||||
compatibility: "low" | "mid" | "high" | "unknown";
|
||||
controllerSupport: "partial" | "full" | "unknown";
|
||||
primaryGenre: string | null;
|
||||
size: { downloadSize: number; sizeOnDisk: number };
|
||||
tags: Array<{ name: string; slug: string; type: "tag" }>;
|
||||
genres: Array<{ type: "genre"; name: string; slug: string }>;
|
||||
developers: Array<{ name: string; slug: string; type: "developer" }>;
|
||||
publishers: Array<{ name: string; slug: string; type: "publisher" }>;
|
||||
}
|
||||
|
||||
export type ImageType =
|
||||
| 'screenshot'
|
||||
| 'boxArt'
|
||||
| 'banner'
|
||||
| 'backdrop'
|
||||
| 'icon'
|
||||
| 'logo'
|
||||
| 'poster'
|
||||
| 'heroArt';
|
||||
|
||||
export interface ImageInfo {
|
||||
type: ImageType;
|
||||
position: number;
|
||||
hash: string;
|
||||
sourceUrl: string | null;
|
||||
format?: string;
|
||||
averageColor: { hex: string; isDark: boolean };
|
||||
dimensions: { width: number; height: number };
|
||||
fileSize: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface CompareOpts {
|
||||
/** Pixelmatch color threshold (0–1). Default: 0.1 */
|
||||
threshold?: number;
|
||||
/** If true, return an image buffer of the diff map. Default: false */
|
||||
diffOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
diffRatio: number;
|
||||
/** Present only if `diffOutput: true` */
|
||||
diffBuffer?: Buffer;
|
||||
}
|
||||
|
||||
export interface Shot {
|
||||
url: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface RankedShot {
|
||||
url: string;
|
||||
score: number;
|
||||
}
|
||||
422
packages/core/src/client/utils.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import type {
|
||||
Tag,
|
||||
StoreTags,
|
||||
AppDepots,
|
||||
GenreType,
|
||||
LibraryAssetsFull,
|
||||
DepotEntry,
|
||||
CompareOpts,
|
||||
CompareResult,
|
||||
RankedShot,
|
||||
Shot,
|
||||
} from "./types";
|
||||
import crypto from 'crypto';
|
||||
import pLimit from 'p-limit';
|
||||
import { PNG } from 'pngjs';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
import { Agent as HttpsAgent } from 'https';
|
||||
import sharp, { type Metadata } from 'sharp';
|
||||
import AbortController from 'abort-controller';
|
||||
import fetch, { RequestInit } from 'node-fetch';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
// --- Configuration ---
|
||||
const httpAgent = new HttpAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const httpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const downloadCache = new LRUCache<string, Buffer>({
|
||||
max: 100,
|
||||
ttl: 1000 * 60 * 30, // 30-minute expiry
|
||||
allowStale: false,
|
||||
});
|
||||
const downloadLimit = pLimit(10); // max concurrent downloads
|
||||
const compareCache = new LRUCache<string, CompareResult>({
|
||||
max: 50,
|
||||
ttl: 1000 * 60 * 10, // 10-minute expiry
|
||||
});
|
||||
|
||||
export namespace Utils {
|
||||
export async function fetchBuffer(url: string, retries = 3): Promise<Buffer> {
|
||||
if (downloadCache.has(url)) {
|
||||
return downloadCache.get(url)!;
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), 15_000);
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent
|
||||
} as RequestInit);
|
||||
clearTimeout(id);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
downloadCache.set(url, buf);
|
||||
return buf;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
if (attempt < retries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
export async function getImageMetadata(buffer: Buffer) {
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
const { width, height, format, size: fileSize } = await sharp(buffer).metadata();
|
||||
if (!width || !height) throw new Error('Invalid dimensions');
|
||||
|
||||
const slice = await sharp(buffer)
|
||||
.resize({ width: Math.min(width, 256) }) // cheap shrink
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
const pixelArray = new Uint8Array(slice.buffer);
|
||||
const { hex, isDark } = fac.prepareResult(fac.getColorFromArray4(pixelArray, { mode: "precision" }));
|
||||
|
||||
return { hash, format, averageColor: { hex, isDark }, dimensions: { width, height }, fileSize, buffer };
|
||||
}
|
||||
|
||||
// --- Optimized Box Art creation ---
|
||||
export async function createBoxArtBuffer(
|
||||
assets: LibraryAssetsFull,
|
||||
appid: number | string,
|
||||
logoPercent = 0.9
|
||||
): Promise<Buffer> {
|
||||
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
|
||||
const pick = (key: string) => {
|
||||
const set = assets[key];
|
||||
const path = set?.image2x?.english || set?.image?.english;
|
||||
if (!path) throw new Error(`Missing asset for ${key}`);
|
||||
return `${base}/${path}`;
|
||||
};
|
||||
|
||||
const [bgBuf, logoBuf] = await Promise.all([
|
||||
downloadLimit(() =>
|
||||
fetchBuffer(pick('library_hero'))
|
||||
.catch(error => {
|
||||
console.error(`Failed to download hero image for ${appid}:`, error);
|
||||
throw new Error(`Failed to create box art: hero image unavailable`);
|
||||
}),
|
||||
),
|
||||
downloadLimit(() => fetchBuffer(pick('library_logo'))
|
||||
.catch(error => {
|
||||
console.error(`Failed to download logo image for ${appid}:`, error);
|
||||
throw new Error(`Failed to create box art: logo image unavailable`);
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const bgImage = sharp(bgBuf);
|
||||
const meta = await bgImage.metadata();
|
||||
if (!meta.width || !meta.height) throw new Error('Invalid background dimensions');
|
||||
const size = Math.min(meta.width, meta.height);
|
||||
const left = Math.floor((meta.width - size) / 2);
|
||||
const top = Math.floor((meta.height - size) / 2);
|
||||
const squareBg = bgImage.extract({ left, top, width: size, height: size });
|
||||
|
||||
// Resize logo
|
||||
const logoTarget = Math.floor(size * logoPercent);
|
||||
const logoResized = await sharp(logoBuf).resize({ width: logoTarget }).toBuffer();
|
||||
const logoMeta = await sharp(logoResized).metadata();
|
||||
if (!logoMeta.width || !logoMeta.height) throw new Error('Invalid logo dimensions');
|
||||
const logoLeft = Math.floor((size - logoMeta.width) / 2);
|
||||
const logoTop = Math.floor((size - logoMeta.height) / 2);
|
||||
|
||||
return await squareBg
|
||||
.composite([{ input: logoResized, left: logoLeft, top: logoTop }])
|
||||
.jpeg({ quality: 100 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from the given URL, with Steam-like headers
|
||||
*/
|
||||
export async function fetchApi<T>(url: string, retries = 3): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Steam 1291812 / iPhone",
|
||||
"Accept-Language": "en-us",
|
||||
},
|
||||
} as RequestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
// Only retry on network errors or 5xx status codes
|
||||
if (error.message.includes('API error: 5') || !error.message.includes('API error')) {
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a name
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s -]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a candidate screenshot against a UI-free baseline to find how much UI/HUD remains.
|
||||
*
|
||||
* @param baselineBuffer - PNG/JPEG buffer of the clean background.
|
||||
* @param candidateBuffer - PNG/JPEG buffer of the screenshot to test.
|
||||
* @param opts - Options.
|
||||
* @returns Promise resolving to diff ratio (and optional diff image).
|
||||
*/
|
||||
export async function compareWithBaseline(
|
||||
baselineBuffer: Buffer,
|
||||
candidateBuffer: Buffer,
|
||||
opts: CompareOpts = {}
|
||||
): Promise<CompareResult> {
|
||||
// Generate cache key from buffer hashes
|
||||
const baseHash = crypto.createHash('md5').update(baselineBuffer).digest('hex');
|
||||
const candHash = crypto.createHash('md5').update(candidateBuffer).digest('hex');
|
||||
const optsKey = JSON.stringify(opts);
|
||||
const cacheKey = `${baseHash}:${candHash}:${optsKey}`;
|
||||
|
||||
// Check cache
|
||||
if (compareCache.has(cacheKey)) {
|
||||
return compareCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const { threshold = 0.1, diffOutput = false } = opts;
|
||||
|
||||
// Get dimensions of baseline
|
||||
const baseMeta: Metadata = await sharp(baselineBuffer).metadata();
|
||||
if (!baseMeta.width || !baseMeta.height) {
|
||||
throw new Error('Invalid baseline dimensions');
|
||||
}
|
||||
|
||||
// Produce PNG buffers of same size
|
||||
const [pngBaseBuf, pngCandBuf] = await Promise.all([
|
||||
sharp(baselineBuffer).png().toBuffer(),
|
||||
sharp(candidateBuffer)
|
||||
.resize(baseMeta.width, baseMeta.height)
|
||||
.png()
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
const imgBase = PNG.sync.read(pngBaseBuf);
|
||||
const imgCand = PNG.sync.read(pngCandBuf);
|
||||
const diffImg = new PNG({ width: baseMeta.width, height: baseMeta.height });
|
||||
|
||||
const numDiff = pixelmatch(
|
||||
imgBase.data,
|
||||
imgCand.data,
|
||||
diffImg.data,
|
||||
baseMeta.width,
|
||||
baseMeta.height,
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
const total = baseMeta.width * baseMeta.height;
|
||||
const diffRatio = numDiff / total;
|
||||
|
||||
const result: CompareResult = { diffRatio };
|
||||
if (diffOutput) {
|
||||
result.diffBuffer = PNG.sync.write(diffImg);
|
||||
}
|
||||
|
||||
compareCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a baseline buffer and an array of screenshots, returns them sorted
|
||||
* ascending by diffRatio (least UI first).
|
||||
*/
|
||||
export async function rankScreenshots(
|
||||
baselineBuffer: Buffer,
|
||||
shots: Shot[],
|
||||
opts: CompareOpts = {}
|
||||
): Promise<RankedShot[]> {
|
||||
// Process up to 5 comparisons in parallel
|
||||
const compareLimit = pLimit(5);
|
||||
|
||||
// Run all comparisons with limited concurrency
|
||||
const results = await Promise.all(
|
||||
shots.map(shot =>
|
||||
compareLimit(async () => {
|
||||
const { diffRatio } = await compareWithBaseline(
|
||||
baselineBuffer,
|
||||
shot.buffer,
|
||||
opts
|
||||
);
|
||||
return { url: shot.url, score: diffRatio };
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return results.sort((a, b) => a.score - b.score);
|
||||
}
|
||||
|
||||
// --- Helpers for URLs ---
|
||||
export function getScreenshotUrls(screenshots: { appid: number; filename: string }[]): string[] {
|
||||
return screenshots.map(s => `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${s.appid}/${s.filename}`);
|
||||
}
|
||||
|
||||
export function getAssetUrls(assets: LibraryAssetsFull, appid: number | string, header: string) {
|
||||
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
|
||||
return {
|
||||
logo: `${base}/${assets.library_logo?.image2x?.english || assets.library_logo?.image?.english}`,
|
||||
backdrop: `${base}/${assets.library_hero?.image2x?.english || assets.library_hero?.image?.english}`,
|
||||
poster: `${base}/${assets.library_capsule?.image2x?.english || assets.library_capsule?.image?.english}`,
|
||||
banner: `${base}/${assets.library_header?.image2x?.english || assets.library_header?.image?.english || header}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a 0–5 score from positive/negative votes using a Wilson score confidence interval.
|
||||
* This formula adjusts the raw ratio based on the total number of votes to account for
|
||||
* statistical confidence. With few votes, the score regresses toward 2.5 (neutral).
|
||||
*
|
||||
* Compute a 0–5 score from positive/negative votes
|
||||
*/
|
||||
export function getRating(positive: number, negative: number): number {
|
||||
const total = positive + negative;
|
||||
if (!total) return 0;
|
||||
const avg = positive / total;
|
||||
// Apply Wilson score confidence adjustment and scale to 0-5 range
|
||||
const score = avg - (avg - 0.5) * Math.pow(2, -Math.log10(total + 1));
|
||||
return Math.round(score * 5 * 10) / 10;
|
||||
}
|
||||
|
||||
export function getAssociationsByTypeWithSlug<
|
||||
T extends "developer" | "publisher"
|
||||
>(
|
||||
associations: Record<string, { name: string; type: string }>,
|
||||
type: T
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
return Object.values(associations)
|
||||
.filter((a) => a.type === type)
|
||||
.map((a) => ({ name: a.name.trim(), slug: createSlug(a.name.trim()), type }));
|
||||
}
|
||||
|
||||
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
|
||||
switch (type) {
|
||||
case "1":
|
||||
return "low";
|
||||
case "2":
|
||||
return "mid";
|
||||
case "3":
|
||||
return "high";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGameTags<
|
||||
T extends string = "tag"
|
||||
>(
|
||||
available: Tag[],
|
||||
storeTags: StoreTags,
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
const tagMap = new Map<number, Tag>(available.map((t) => [t.tagid, t]));
|
||||
const result: Array<{ name: string; slug: string; type: T }> = Object.values(storeTags)
|
||||
.map((id) => tagMap.get(Number(id)))
|
||||
.filter((t): t is Tag => Boolean(t))
|
||||
.map((t) => ({ name: t.name.trim(), slug: createSlug(t.name), type: 'tag' as T }));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tag object with name, slug, and type
|
||||
* @typeparam T Literal type of the `type` field (defaults to 'tag')
|
||||
*/
|
||||
export function createTag<
|
||||
T extends string = 'tag'
|
||||
>(
|
||||
name: string,
|
||||
type?: T
|
||||
): { name: string; slug: string; type: T } {
|
||||
const tagType = (type ?? 'tag') as T;
|
||||
return {
|
||||
name: name.trim(),
|
||||
slug: createSlug(name),
|
||||
type: tagType,
|
||||
};
|
||||
}
|
||||
|
||||
export function capitalise(name: string) {
|
||||
return name
|
||||
.charAt(0) // first character
|
||||
.toUpperCase() // make it uppercase
|
||||
+ name
|
||||
.slice(1) // rest of the string
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function getPublicDepotSizes(depots: AppDepots) {
|
||||
const sum = { download: 0, size: 0 };
|
||||
for (const key in depots) {
|
||||
if (key === 'branches' || key === 'privatebranches') continue;
|
||||
const entry = depots[key] as DepotEntry;
|
||||
if ('manifests' in entry && entry.manifests.public) {
|
||||
sum.download += Number(entry.manifests.public.download);
|
||||
sum.size += Number(entry.manifests.public.size);
|
||||
}
|
||||
}
|
||||
return { downloadSize: sum.download, sizeOnDisk: sum.size };
|
||||
}
|
||||
|
||||
export function parseGenres(str: string): GenreType[] {
|
||||
return str.split(',')
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean)
|
||||
.map((g) => ({ type: 'genre', name: g.trim(), slug: createSlug(g) }));
|
||||
}
|
||||
|
||||
export function getPrimaryGenre(
|
||||
genres: GenreType[],
|
||||
map: Record<string, string>,
|
||||
primaryId: string
|
||||
): string | null {
|
||||
const idx = Object.keys(map).find((k) => map[k] === primaryId);
|
||||
return idx !== undefined ? genres[Number(idx)]?.name : null;
|
||||
}
|
||||
|
||||
export function cleanDescription(input: string): string {
|
||||
|
||||
const cleaned = sanitizeHtml(input, {
|
||||
allowedTags: [], // no tags allowed
|
||||
allowedAttributes: {}, // no attributes anywhere
|
||||
textFilter: (text) => text.replace(/\s+/g, ' '), // collapse runs of whitespace
|
||||
});
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { pgTable, varchar } from "drizzle-orm/pg-core";
|
||||
import { encryptedText, timestamps, utc } from "../drizzle/types";
|
||||
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
import { encryptedText, ulid, timestamps, utc } from "../drizzle/types";
|
||||
|
||||
export const steamCredentialsTable = pgTable(
|
||||
"steam_account_credentials",
|
||||
{
|
||||
...timestamps,
|
||||
id: varchar("steam_id", { length: 255 })
|
||||
id: ulid("id").notNull(),
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
@@ -16,5 +16,10 @@ export const steamCredentialsTable = pgTable(
|
||||
.notNull(),
|
||||
expiry: utc("expiry").notNull(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
}
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.id]
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { createID, fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { createEvent } from "../event";
|
||||
import { createID, fn } from "../utils";
|
||||
import { eq, and, isNull, gt } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamCredentialsTable } from "./credentials.sql";
|
||||
@@ -22,39 +22,41 @@ export namespace Credentials {
|
||||
New: createEvent(
|
||||
"new_credentials.added",
|
||||
z.object({
|
||||
steamID: Info.shape.id,
|
||||
steamID: Info.shape.steamID,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({ accessToken: true, cookies: true, expiry: true }),
|
||||
.omit({ accessToken: true, cookies: true, expiry: true })
|
||||
.partial({ id: true }),
|
||||
(input) => {
|
||||
const part = input.refreshToken.split('.')[1] as string
|
||||
|
||||
const payload = JSON.parse(Buffer.from(part, 'base64').toString());
|
||||
|
||||
return createTransaction(async (tx) => {
|
||||
const id = input.id
|
||||
const id = input.id ?? createID("credentials")
|
||||
await tx
|
||||
.insert(steamCredentialsTable)
|
||||
.values({
|
||||
id,
|
||||
steamID: input.steamID,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
expiry: new Date(payload.exp * 1000),
|
||||
})
|
||||
// await afterTx(async () =>
|
||||
// await bus.publish(Resource.Bus, Events.New, { steamID: input.id })
|
||||
// );
|
||||
await afterTx(async () =>
|
||||
await bus.publish(Resource.Bus, Events.New, { steamID: input.steamID })
|
||||
);
|
||||
return id
|
||||
})
|
||||
});
|
||||
|
||||
export const getByID = fn(
|
||||
Info.shape.id,
|
||||
(id) =>
|
||||
export const fromSteamID = fn(
|
||||
Info.shape.steamID,
|
||||
(steamID) =>
|
||||
useTransaction(async (tx) => {
|
||||
const now = new Date()
|
||||
|
||||
@@ -63,7 +65,7 @@ export namespace Credentials {
|
||||
.from(steamCredentialsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamCredentialsTable.id, id),
|
||||
eq(steamCredentialsTable.steamID, steamID),
|
||||
isNull(steamCredentialsTable.timeDeleted),
|
||||
gt(steamCredentialsTable.expiry, now)
|
||||
)
|
||||
@@ -76,31 +78,6 @@ export namespace Credentials {
|
||||
return serialize(credential);
|
||||
})
|
||||
);
|
||||
|
||||
// export const getBySteamID = fn(
|
||||
// Info.shape.steamID,
|
||||
// (steamID) =>
|
||||
// useTransaction(async (tx) => {
|
||||
// const now = new Date()
|
||||
|
||||
// const credential = await tx
|
||||
// .select()
|
||||
// .from(steamCredentialsTable)
|
||||
// .where(
|
||||
// and(
|
||||
// eq(steamCredentialsTable.steamID, steamID),
|
||||
// isNull(steamCredentialsTable.timeDeleted),
|
||||
// gt(steamCredentialsTable.expiry, now)
|
||||
// )
|
||||
// )
|
||||
// .execute()
|
||||
// .then(rows => rows.at(0));
|
||||
|
||||
// if (!credential) return null;
|
||||
|
||||
// return serialize(credential);
|
||||
// })
|
||||
// );
|
||||
|
||||
export function serialize(
|
||||
input: typeof steamCredentialsTable.$inferSelect,
|
||||
@@ -108,6 +85,7 @@ export namespace Credentials {
|
||||
return {
|
||||
id: input.id,
|
||||
expiry: input.expiry,
|
||||
steamID: input.steamID,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
};
|
||||
|
||||
@@ -251,9 +251,9 @@ export namespace Examples {
|
||||
screenshots: CommonImg,
|
||||
boxArts: CommonImg,
|
||||
posters: CommonImg,
|
||||
banners: CommonImg,
|
||||
heroArts: CommonImg,
|
||||
superHeroArts: CommonImg,
|
||||
backgrounds: CommonImg,
|
||||
backdrops: CommonImg,
|
||||
logos: CommonImg,
|
||||
icons: CommonImg,
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Categories } from "../categories";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { imagesTable } from "../images/images.sql";
|
||||
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
@@ -22,7 +22,7 @@ export namespace Game {
|
||||
example: Examples.Game
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const InputInfo = createSelectSchema(gamesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
@@ -31,7 +31,7 @@ export namespace Game {
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const results =
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
.from(gamesTable)
|
||||
@@ -43,9 +43,11 @@ export namespace Game {
|
||||
isNull(gamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (results.length > 0) return null
|
||||
if (result) return result.baseGameID
|
||||
|
||||
await tx
|
||||
.insert(gamesTable)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, integer, json, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
import { z } from "zod";
|
||||
|
||||
export const ImageTypeEnum = pgEnum("image_type", ["heroArt", "icon", "logo", "superHeroArt", "poster", "boxArt", "screenshot","background"])
|
||||
export const ImageTypeEnum = pgEnum("image_type", ["heroArt", "icon", "logo", "banner", "poster", "boxArt", "screenshot", "backdrop"])
|
||||
|
||||
export const ImageDimensions = z.object({
|
||||
width: z.number().int(),
|
||||
@@ -11,8 +11,8 @@ export const ImageDimensions = z.object({
|
||||
})
|
||||
|
||||
export const ImageColor = z.object({
|
||||
hex: z.string(),
|
||||
isDark: z.boolean()
|
||||
hex: z.string(),
|
||||
isDark: z.boolean()
|
||||
})
|
||||
|
||||
export type ImageColor = z.infer<typeof ImageColor>;
|
||||
@@ -30,7 +30,7 @@ export const imagesTable = pgTable(
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
sourceUrl: text("source_url").notNull(),
|
||||
sourceUrl: text("source_url"), // The BoxArt is source Url will always be null;
|
||||
position: integer("position").notNull().default(0),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
dimensions: json("dimensions").$type<ImageDimensions>().notNull(),
|
||||
|
||||
@@ -38,17 +38,17 @@ export namespace Images {
|
||||
description: "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters",
|
||||
example: Examples.Images.posters
|
||||
}),
|
||||
heroArts: Image.array().openapi({
|
||||
banners: Image.array().openapi({
|
||||
description: "Horizontal promotional artwork optimized for header displays and banners",
|
||||
example: Examples.Images.banners
|
||||
}),
|
||||
heroArts: Image.array().openapi({
|
||||
description: "High-resolution, wide-format artwork designed for featured content and main entries",
|
||||
example: Examples.Images.heroArts
|
||||
}),
|
||||
superHeroArts: Image.array().openapi({
|
||||
description: "High-resolution, wide-format artwork designed for featured content and main entries",
|
||||
example: Examples.Images.superHeroArts
|
||||
}),
|
||||
backgrounds: Image.array().openapi({
|
||||
description: "Full-width background images optimized for page layouts and decorative purposes",
|
||||
example: Examples.Images.backgrounds
|
||||
backdrops: Image.array().openapi({
|
||||
description: "Full-width backdrop images optimized for page layouts and decorative purposes",
|
||||
example: Examples.Images.backdrops
|
||||
}),
|
||||
logos: Image.array().openapi({
|
||||
description: "Official game logo artwork, typically with transparent backgrounds for flexible placement",
|
||||
@@ -107,10 +107,10 @@ export namespace Images {
|
||||
}, {
|
||||
screenshots: [],
|
||||
boxArts: [],
|
||||
superHeroArts: [],
|
||||
banners: [],
|
||||
heroArts: [],
|
||||
posters: [],
|
||||
backgrounds: [],
|
||||
backdrops: [],
|
||||
icons: [],
|
||||
logos: [],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Game } from "../game";
|
||||
import { Actor } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
import { gamesTable } from "../game/game.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamLibraryTable } from "./library.sql";
|
||||
@@ -17,31 +18,61 @@ export namespace Library {
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Queue: createEvent(
|
||||
"library.queue",
|
||||
z.object({
|
||||
appID: z.number(),
|
||||
lastPlayed: z.date(),
|
||||
timeAcquired: z.date(),
|
||||
totalPlaytime: z.number(),
|
||||
isFamilyShared: z.boolean(),
|
||||
isFamilyShareable: z.boolean(),
|
||||
}).array(),
|
||||
),
|
||||
};
|
||||
|
||||
export const add = fn(
|
||||
Info,
|
||||
Info.partial({ ownerID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const results =
|
||||
const ownerSteamID = input.ownerID ?? Actor.steamID()
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
eq(steamLibraryTable.ownerID, input.ownerID),
|
||||
eq(steamLibraryTable.ownerID, ownerSteamID),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (results.length > 0) return null
|
||||
if (result) return result.baseGameID
|
||||
|
||||
await tx
|
||||
.insert(steamLibraryTable)
|
||||
.values(input)
|
||||
.values({
|
||||
ownerID: ownerSteamID,
|
||||
baseGameID: input.baseGameID,
|
||||
lastPlayed: input.lastPlayed,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
timeAcquired: input.timeAcquired,
|
||||
isFamilyShared: input.isFamilyShared
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [steamLibraryTable.ownerID, steamLibraryTable.baseGameID],
|
||||
set: { timeDeleted: null }
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
lastPlayed: input.lastPlayed,
|
||||
timeAcquired: input.timeAcquired,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
isFamilyShared: input.isFamilyShared
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { timestamps, } from "../drizzle/types";
|
||||
import { timestamps, utc, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
import { boolean, index, integer, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
|
||||
//TODO: Add playtime here
|
||||
export const steamLibraryTable = pgTable(
|
||||
"game_libraries",
|
||||
{
|
||||
@@ -18,6 +17,10 @@ export const steamLibraryTable = pgTable(
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
timeAcquired: utc("time_acquired").notNull(),
|
||||
lastPlayed: utc("last_played").notNull(),
|
||||
totalPlaytime: integer("total_playtime").notNull(),
|
||||
isFamilyShared: boolean("is_family_shared").notNull()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createID, fn, Invite } from "../utils";
|
||||
import { memberTable } from "../member/member.sql";
|
||||
import { groupBy, pipe, values, map } from "remeda";
|
||||
import { createTransaction, useTransaction, type Transaction } from "../drizzle/transaction";
|
||||
import { VisibleError } from "../error";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
@@ -144,6 +143,28 @@ export namespace Team {
|
||||
.then((rows) => serialize(rows))
|
||||
)
|
||||
|
||||
export const fromSlug = fn(
|
||||
Info.shape.slug,
|
||||
(slug) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.innerJoin(memberTable, eq(memberTable.teamID, teamTable.id))
|
||||
.innerJoin(steamTable, eq(memberTable.steamID, steamTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, Actor.userID()),
|
||||
isNull(memberTable.timeDeleted),
|
||||
isNull(steamTable.timeDeleted),
|
||||
isNull(teamTable.timeDeleted),
|
||||
eq(teamTable.slug, slug),
|
||||
)
|
||||
)
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: { teams: typeof teamTable.$inferSelect; steam_accounts: typeof steamTable.$inferSelect | null }[]
|
||||
): z.infer<typeof Info>[] {
|
||||
@@ -158,10 +179,9 @@ export namespace Team {
|
||||
ownerID: group[0].teams.ownerID,
|
||||
maxMembers: group[0].teams.maxMembers,
|
||||
inviteCode: group[0].teams.inviteCode,
|
||||
members:
|
||||
!group[0].steam_accounts ?
|
||||
[] :
|
||||
group.map((item) => Steam.serialize(item.steam_accounts!))
|
||||
members: group.map(i => i.steam_accounts)
|
||||
.filter((c): c is typeof steamTable.$inferSelect => Boolean(c))
|
||||
.map((item) => Steam.serialize(item))
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
10
packages/core/src/utils/helper.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function chunkArray<T>(arr: T[], chunkSize: number): T[][] {
|
||||
if (chunkSize <= 0) {
|
||||
throw new Error("chunkSize must be a positive integer");
|
||||
}
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
chunks.push(arr.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export * from "./fn"
|
||||
export * from "./log"
|
||||
export * from "./id"
|
||||
export * from "./invite"
|
||||
export * from "./token"
|
||||
export * from "./token"
|
||||
export * from "./helper"
|
||||
@@ -16,6 +16,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actor-core/bun": "^0.8.0",
|
||||
"@actor-core/file-system": "^0.8.0",
|
||||
"@aws-sdk/client-s3": "^3.806.0",
|
||||
"@aws-sdk/client-sqs": "^3.806.0",
|
||||
"@nestri/core": "workspace:",
|
||||
"actor-core": "^0.8.0",
|
||||
"hono": "^4.7.8",
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Resource } from "sst"
|
||||
import { Resource } from "sst";
|
||||
import { type Env } from "hono";
|
||||
import { PasswordUI, Select } from "./ui";
|
||||
import { logger } from "hono/logger";
|
||||
import { subjects } from "../subjects"
|
||||
import { subjects } from "../subjects";
|
||||
import { PasswordUI, Select } from "./ui";
|
||||
import { issuer } from "@openauthjs/openauth";
|
||||
import { User } from "@nestri/core/user/index"
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Email } from "@nestri/core/email/index";
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import { handleDiscord, handleGithub } from "./utils";
|
||||
@@ -26,7 +26,7 @@ const app = issuer({
|
||||
logo: "https://nestri.io/logo.webp",
|
||||
favicon: "https://nestri.io/seo/favicon.ico",
|
||||
background: {
|
||||
light: "#f5f5f5 ",
|
||||
light: "#F5F5F5",
|
||||
dark: "#171717"
|
||||
},
|
||||
radius: "lg",
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import SteamID from "steamid"
|
||||
import { bus } from "sst/aws/bus";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { Credentials } from "@nestri/core/credentials/index";
|
||||
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
|
||||
|
||||
export const handler = bus.subscriber(
|
||||
[Credentials.Events.New],
|
||||
async (event) => {
|
||||
console.log(event.type, event.properties, event.metadata);
|
||||
switch (event.type) {
|
||||
case "new_credentials.added": {
|
||||
const input = event.properties
|
||||
const credentials = await Credentials.getByID(input.steamID)
|
||||
if (credentials) {
|
||||
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
|
||||
|
||||
session.refreshToken = credentials.refreshToken;
|
||||
|
||||
const cookies = await session.getWebCookies()
|
||||
|
||||
const community = new SteamCommunity()
|
||||
community.setCookies(cookies);
|
||||
|
||||
//FIXME: use a promise as promises inside callbacks are not awaited
|
||||
community.getFriendsList((error, allFriends) => {
|
||||
if (!error) {
|
||||
const friends = Object.entries(allFriends);
|
||||
for (const [id, nonce] of friends) {
|
||||
const friendID = new SteamID(id);
|
||||
community.getSteamUser(friendID, async (error, user) => {
|
||||
if (!error) {
|
||||
const wasAdded =
|
||||
await Steam.create({
|
||||
id: friendID.toString(),
|
||||
name: user.name,
|
||||
realName: user.realName,
|
||||
avatarHash: user.avatarHash,
|
||||
steamMemberSince: user.memberSince,
|
||||
profileUrl: user.customURL?.trim() || null,
|
||||
limitations: {
|
||||
isLimited: user.isLimitedAccount,
|
||||
isVacBanned: user.vacBanned,
|
||||
tradeBanState: user.tradeBanState.toLowerCase() as any,
|
||||
privacyState: user.privacyState as any,
|
||||
visibilityState: Number(user.visibilityState)
|
||||
}
|
||||
})
|
||||
|
||||
if (!wasAdded) {
|
||||
console.log(`steam user ${friendID.toString()} already exists`)
|
||||
}
|
||||
|
||||
await Friend.add({ friendSteamID: friendID.toString(), steamID: input.steamID })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
117
packages/functions/src/events/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { Images } from "@nestri/core/images/index";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { BaseGame } from "@nestri/core/base-game/index";
|
||||
import { Credentials } from "@nestri/core/credentials/index";
|
||||
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
|
||||
import { PutObjectCommand, S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const s3 = new S3Client({});
|
||||
|
||||
export const handler = bus.subscriber(
|
||||
[Credentials.Events.New, BaseGame.Events.New],
|
||||
async (event) => {
|
||||
console.log(event.type, event.properties, event.metadata);
|
||||
switch (event.type) {
|
||||
case "new_credentials.added": {
|
||||
const input = event.properties
|
||||
const credentials = await Credentials.fromSteamID(input.steamID)
|
||||
if (credentials) {
|
||||
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
|
||||
|
||||
session.refreshToken = credentials.refreshToken;
|
||||
|
||||
const cookies = await session.getWebCookies();
|
||||
|
||||
const friends = await Client.getFriendsList(cookies);
|
||||
|
||||
const putFriends = friends.map(async (user) => {
|
||||
const wasAdded =
|
||||
await Steam.create({
|
||||
id: user.steamID.toString(),
|
||||
name: user.name,
|
||||
realName: user.realName,
|
||||
avatarHash: user.avatarHash,
|
||||
steamMemberSince: user.memberSince,
|
||||
profileUrl: user.customURL?.trim() || null,
|
||||
limitations: {
|
||||
isLimited: user.isLimitedAccount,
|
||||
isVacBanned: user.vacBanned,
|
||||
tradeBanState: user.tradeBanState.toLowerCase() as any,
|
||||
privacyState: user.privacyState as any,
|
||||
visibilityState: Number(user.visibilityState)
|
||||
}
|
||||
})
|
||||
|
||||
if (!wasAdded) {
|
||||
console.log(`Steam user ${user.steamID.toString()} already exists`)
|
||||
}
|
||||
|
||||
await Friend.add({ friendSteamID: user.steamID.toString(), steamID: input.steamID })
|
||||
})
|
||||
|
||||
const settled = await Promise.allSettled(putFriends);
|
||||
|
||||
settled
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "new_game.added": {
|
||||
const input = event.properties
|
||||
// Get images and save to s3
|
||||
const images = await Client.getImages(input.appID);
|
||||
|
||||
(await Promise.allSettled(
|
||||
images.map(async (image) => {
|
||||
// Put the images into the db
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
baseGameID: input.appID,
|
||||
position: image.position,
|
||||
fileSize: image.fileSize,
|
||||
sourceUrl: image.sourceUrl,
|
||||
dimensions: image.dimensions,
|
||||
extractedColor: image.averageColor,
|
||||
});
|
||||
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
// Save to s3 because it doesn't already exist
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
Body: image.buffer,
|
||||
...(image.format && { ContentType: `image/${image.format}` }),
|
||||
Metadata: {
|
||||
type: image.type,
|
||||
appID: input.appID,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
})
|
||||
))
|
||||
.filter(i => i.status === "rejected")
|
||||
.forEach(r => console.warn("[createImages] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
87
packages/functions/src/queues/library.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { SQSHandler } from "aws-lambda";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { Game } from "@nestri/core/game/index";
|
||||
import { Utils } from "@nestri/core/client/utils";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { Library } from "@nestri/core/library/index";
|
||||
import { BaseGame } from "@nestri/core/base-game/index";
|
||||
import { Categories } from "@nestri/core/categories/index";
|
||||
|
||||
export const handler: SQSHandler = async (event) => {
|
||||
for (const record of event.Records) {
|
||||
const parsed = JSON.parse(
|
||||
record.body,
|
||||
) as typeof Library.Events.Queue.$payload;
|
||||
|
||||
await Actor.provide(
|
||||
parsed.metadata.actor.type,
|
||||
parsed.metadata.actor.properties,
|
||||
async () => {
|
||||
const processGames = parsed.properties.map(async (game) => {
|
||||
// First check whether the base_game exists, if not get it
|
||||
const appID = game.appID.toString()
|
||||
const exists = await BaseGame.fromID(appID)
|
||||
|
||||
if (!exists) {
|
||||
const appInfo = await Client.getAppInfo(appID);
|
||||
const tags = appInfo.tags;
|
||||
|
||||
await BaseGame.create({
|
||||
id: appID,
|
||||
name: appInfo.name,
|
||||
size: appInfo.size,
|
||||
score: appInfo.score,
|
||||
slug: appInfo.slug,
|
||||
description: appInfo.description,
|
||||
releaseDate: appInfo.releaseDate,
|
||||
primaryGenre: appInfo.primaryGenre,
|
||||
compatibility: appInfo.compatibility,
|
||||
controllerSupport: appInfo.controllerSupport,
|
||||
})
|
||||
|
||||
if (game.isFamilyShareable) {
|
||||
tags.push(Utils.createTag("Family Share"))
|
||||
}
|
||||
|
||||
const allCategories = [...tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers]
|
||||
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
uniqueCategories.map(async (cat) => {
|
||||
//Use a single db transaction to get or set the category
|
||||
await Categories.create({
|
||||
type: cat.type, slug: cat.slug, name: cat.name
|
||||
})
|
||||
|
||||
// Use a single db transaction to get or create the game
|
||||
await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type })
|
||||
})
|
||||
)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[uniqueCategories] failed:", (r as PromiseRejectedResult).reason));
|
||||
}
|
||||
|
||||
// Add to user's library
|
||||
await Library.add({
|
||||
baseGameID: appID,
|
||||
lastPlayed: game.lastPlayed,
|
||||
timeAcquired: game.timeAcquired,
|
||||
totalPlaytime: game.totalPlaytime,
|
||||
isFamilyShared: game.isFamilyShared,
|
||||
})
|
||||
})
|
||||
|
||||
const settled = await Promise.allSettled(processGames)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[processGames] failed:", (r as PromiseRejectedResult).reason));
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -92,23 +92,24 @@ export const App: Component = () => {
|
||||
const storage = useStorage();
|
||||
|
||||
return (
|
||||
<OpenAuthProvider
|
||||
issuer={import.meta.env.VITE_AUTH_URL}
|
||||
clientID="web"
|
||||
>
|
||||
// <OpenAuthProvider
|
||||
// issuer={import.meta.env.VITE_AUTH_URL}
|
||||
// clientID="web"
|
||||
// >
|
||||
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
component={(props) => (
|
||||
<AccountProvider
|
||||
loadingUI={
|
||||
<FullScreen>
|
||||
<Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity…</Text>
|
||||
</FullScreen>
|
||||
}>
|
||||
{props.children}
|
||||
</AccountProvider>
|
||||
// <AccountProvider
|
||||
// loadingUI={
|
||||
// <FullScreen>
|
||||
// <Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity…</Text>
|
||||
// </FullScreen>
|
||||
// }>
|
||||
// {props.children}
|
||||
props.children
|
||||
// </AccountProvider>
|
||||
)}
|
||||
>
|
||||
<Route path=":teamSlug">{TeamRoute}</Route>
|
||||
@@ -141,6 +142,6 @@ export const App: Component = () => {
|
||||
</Route>
|
||||
</Router>
|
||||
</Root>
|
||||
</OpenAuthProvider>
|
||||
// </OpenAuthProvider>
|
||||
)
|
||||
}
|
||||
BIN
packages/www/src/assets/games/1.png
Normal file
|
After Width: | Height: | Size: 625 KiB |
BIN
packages/www/src/assets/games/10.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
packages/www/src/assets/games/11.png
Normal file
|
After Width: | Height: | Size: 800 KiB |
BIN
packages/www/src/assets/games/12.png
Normal file
|
After Width: | Height: | Size: 495 KiB |
BIN
packages/www/src/assets/games/13.png
Normal file
|
After Width: | Height: | Size: 779 KiB |
BIN
packages/www/src/assets/games/14.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
packages/www/src/assets/games/15.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
packages/www/src/assets/games/16.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
packages/www/src/assets/games/17.png
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
packages/www/src/assets/games/18.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
packages/www/src/assets/games/19.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
packages/www/src/assets/games/2.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
packages/www/src/assets/games/20.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
packages/www/src/assets/games/21.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
packages/www/src/assets/games/22.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
packages/www/src/assets/games/23.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
packages/www/src/assets/games/24.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
packages/www/src/assets/games/25.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
packages/www/src/assets/games/26.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
packages/www/src/assets/games/27.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
packages/www/src/assets/games/28.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
packages/www/src/assets/games/29.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
packages/www/src/assets/games/3.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
packages/www/src/assets/games/30.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
packages/www/src/assets/games/4.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
packages/www/src/assets/games/5.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
packages/www/src/assets/games/6.png
Normal file
|
After Width: | Height: | Size: 590 KiB |
BIN
packages/www/src/assets/games/7.png
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
packages/www/src/assets/games/8.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
packages/www/src/assets/games/9.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
@@ -1,5 +1,5 @@
|
||||
import { animate, scroll } from "motion"
|
||||
import { A } from "@solidjs/router";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { Container } from "@nestri/www/ui";
|
||||
import Avatar from "@nestri/www/ui/avatar";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
@@ -200,6 +200,15 @@ const Nav = styled("nav", {
|
||||
}
|
||||
})
|
||||
|
||||
const capitalize = (name: string) => {
|
||||
return name
|
||||
.charAt(0) // first character
|
||||
.toUpperCase() // make it uppercase
|
||||
+ name
|
||||
.slice(1) // rest of the string
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the application's fixed top navigation bar with branding, team information, and navigation links.
|
||||
*
|
||||
@@ -230,7 +239,8 @@ export function Header(props: ParentProps) {
|
||||
})
|
||||
|
||||
// const account = useAccount()
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<NavWrapper scrolled={hasScrolled()}>
|
||||
@@ -313,6 +323,21 @@ export function Header(props: ParentProps) {
|
||||
d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" />
|
||||
</DropIcon>
|
||||
</TeamRoot>
|
||||
{/**Fixme, this does not work for us */}
|
||||
<Show when={location.pathname.split("/").pop() !== "home"} >
|
||||
<LineSvg
|
||||
height="16"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" fill="currentColor"></path>
|
||||
</LineSvg>
|
||||
<div>{capitalize(location.pathname.split("/").pop()!)}</div>
|
||||
</Show>
|
||||
|
||||
</LogoRoot>
|
||||
{/* </Show> */}
|
||||
</Container>
|
||||
@@ -332,17 +357,17 @@ export function Header(props: ParentProps) {
|
||||
</NavLink>
|
||||
</NavRoot>
|
||||
</Show>
|
||||
<div style={{ "margin-bottom": "2px" }} >
|
||||
{/* <div style={{ "margin-bottom": "2px" }} >
|
||||
<AvatarImg src={"https://avatars.githubusercontent.com/u/71614375?v=4"} alt={`Wanjohi's avatar`} />
|
||||
{/* <Switch>
|
||||
<Switch>
|
||||
<Match when={account.current.avatarUrl} >
|
||||
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
|
||||
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
|
||||
</Match>
|
||||
<Match when={!account.current.avatarUrl}>
|
||||
<Avatar size={32} name={`${account.current.name}#${account.current.discriminator}`} />
|
||||
</Match>
|
||||
</Switch> */}
|
||||
</div>
|
||||
</Switch>
|
||||
</div> */}
|
||||
</RightRoot>
|
||||
</Nav>
|
||||
</NavWrapper>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FullScreen, theme } from "@nestri/www/ui";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { useSteam } from "@nestri/www/providers/steam";
|
||||
import { Modal } from "@nestri/www/ui/modal";
|
||||
import { createEffect, createSignal, Match, onCleanup, Switch } from "solid-js";
|
||||
import { Text } from "@nestri/www/ui/text"
|
||||
@@ -411,6 +410,7 @@ export function HomeRoute() {
|
||||
<Portal />
|
||||
</PortalContainer>
|
||||
</LastPlayedWrapper>
|
||||
*/}
|
||||
<GamesContainer>
|
||||
<GamesWrapper>
|
||||
<GameSquareImage draggable={false} alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/05/15/acshadows-1715789601294.jpg" />
|
||||
@@ -508,7 +508,7 @@ export function HomeRoute() {
|
||||
</SteamGameContainer>
|
||||
</div>
|
||||
</SteamLibrary>
|
||||
</GamesContainer>*/}
|
||||
</GamesContainer>
|
||||
</FullScreen>
|
||||
</Header>
|
||||
</>
|
||||
|
||||
@@ -1,69 +1,66 @@
|
||||
import { HomeRoute } from "./home";
|
||||
import { LibraryRoute } from "./library";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { Route, useParams } from "@solidjs/router";
|
||||
import { ApiProvider } from "@nestri/www/providers/api";
|
||||
import { SteamRoute } from "@nestri/www/pages/team/steam";
|
||||
import { ZeroProvider } from "@nestri/www/providers/zero";
|
||||
import { TeamContext } from "@nestri/www/providers/context";
|
||||
import { SteamProvider } from "@nestri/www/providers/steam";
|
||||
import { createEffect, createMemo, Match, Switch } from "solid-js";
|
||||
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
|
||||
import { useAccount, useStorage } from "@nestri/www/providers/account";
|
||||
|
||||
export const TeamRoute = (
|
||||
<Route
|
||||
component={(props) => {
|
||||
const params = useParams();
|
||||
const account = useAccount();
|
||||
const storage = useStorage();
|
||||
const openauth = useOpenAuth();
|
||||
// component={(props) => {
|
||||
// const params = useParams();
|
||||
// const account = useAccount();
|
||||
// const storage = useStorage();
|
||||
// const openauth = useOpenAuth();
|
||||
|
||||
const team = createMemo(() =>
|
||||
account.current.teams.find(
|
||||
(item) => item.slug === params.teamSlug,
|
||||
),
|
||||
);
|
||||
// const team = createMemo(() =>
|
||||
// account.current.teams.find(
|
||||
// (item) => item.slug === params.teamSlug,
|
||||
// ),
|
||||
// );
|
||||
|
||||
createEffect(() => {
|
||||
const t = team();
|
||||
if (!t) return;
|
||||
storage.set("team", t.id);
|
||||
});
|
||||
// createEffect(() => {
|
||||
// const t = team();
|
||||
// if (!t) return;
|
||||
// storage.set("team", t.id);
|
||||
// });
|
||||
|
||||
createEffect(() => {
|
||||
const teamSlug = params.teamSlug;
|
||||
for (const item of Object.values(account.all)) {
|
||||
for (const team of item.teams) {
|
||||
if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
|
||||
openauth.switch(item.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// createEffect(() => {
|
||||
// const teamSlug = params.teamSlug;
|
||||
// for (const item of Object.values(account.all)) {
|
||||
// for (const team of item.teams) {
|
||||
// if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
|
||||
// openauth.switch(item.email);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!team()}>
|
||||
{/* TODO: Add a public page for (other) teams */}
|
||||
<NotAllowed header />
|
||||
</Match>
|
||||
<Match when={team()}>
|
||||
<TeamContext.Provider value={() => team()!}>
|
||||
<ZeroProvider>
|
||||
<ApiProvider>
|
||||
<SteamProvider>
|
||||
{props.children}
|
||||
</SteamProvider>
|
||||
</ApiProvider>
|
||||
</ZeroProvider>
|
||||
</TeamContext.Provider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
>
|
||||
// return (
|
||||
// <Switch>
|
||||
// <Match when={!team()}>
|
||||
// {/* TODO: Add a public page for (other) teams */}
|
||||
// <NotAllowed header />
|
||||
// </Match>
|
||||
// <Match when={team()}>
|
||||
// <TeamContext.Provider value={() => team()!}>
|
||||
// <ZeroProvider>
|
||||
// <ApiProvider>
|
||||
// {props.children}
|
||||
// </ApiProvider>
|
||||
// </ZeroProvider>
|
||||
// </TeamContext.Provider>
|
||||
// </Match>
|
||||
// </Switch>
|
||||
// )
|
||||
// }}
|
||||
>
|
||||
<Route path="" component={HomeRoute} />
|
||||
<Route path="steam" component={SteamRoute} />
|
||||
<Route path="library" component={LibraryRoute} />
|
||||
<Route path="*" component={() => <NotFound header />} />
|
||||
</Route>
|
||||
)
|
||||
179
packages/www/src/pages/team/library.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { For } from "solid-js";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { FullScreen, theme } from "@nestri/www/ui";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
|
||||
const Container = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
zIndex: 10,
|
||||
isolation: "isolate",
|
||||
marginTop: 30,
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = styled("div", {
|
||||
base: {
|
||||
maxWidth: "70vw",
|
||||
width: "100%",
|
||||
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
|
||||
margin: "0 auto",
|
||||
display: "grid",
|
||||
columnGap: 12,
|
||||
rowGap: 10
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const SquareImage = styled("img", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
userSelect: "none",
|
||||
aspectRatio: "1/1",
|
||||
borderRadius: 10,
|
||||
transitionDuration: "0.4s",
|
||||
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
|
||||
transitionProperty: "opacity",
|
||||
cursor: "pointer",
|
||||
border: `3px solid transparent`,
|
||||
":hover": {
|
||||
// transform: "scale(1.01)",
|
||||
outline: `3px solid ${theme.color.brand}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const TitleHeader = styled("header", {
|
||||
base: {
|
||||
borderBottom: `1px solid ${theme.color.gray.d400}`,
|
||||
color: theme.color.d1000.gray
|
||||
}
|
||||
})
|
||||
|
||||
const TitleWrapper = styled("div", {
|
||||
base: {
|
||||
width: "calc(1000px + calc(2 * 24px))",
|
||||
paddingLeft: "24px",
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
paddingRight: "24px",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
maxWidth: "100%"
|
||||
}
|
||||
})
|
||||
|
||||
const TitleContainer = styled("div", {
|
||||
base: {
|
||||
margin: "40px 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
width: "100%",
|
||||
minWidth: 0
|
||||
}
|
||||
})
|
||||
|
||||
const Title = styled("h1", {
|
||||
base: {
|
||||
lineHeight: "2.5rem",
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
letterSpacing: "-0.069375rem",
|
||||
textAlign: "left",
|
||||
fontSize: theme.font.size["4xl"],
|
||||
textTransform: "capitalize"
|
||||
}
|
||||
})
|
||||
|
||||
const Description = styled("p", {
|
||||
base: {
|
||||
fontSize: theme.font.size.sm,
|
||||
lineHeight: "1.25rem",
|
||||
textAlign: "left",
|
||||
fontWeight: theme.font.weight.regular,
|
||||
letterSpacing: "initial",
|
||||
color: theme.color.gray.d900
|
||||
}
|
||||
})
|
||||
|
||||
const LogoFooter = styled("section", {
|
||||
base: {
|
||||
position: "relative",
|
||||
bottom: -1,
|
||||
fontSize: "100%",
|
||||
maxWidth: 1440,
|
||||
width: "100%",
|
||||
pointerEvents: "none",
|
||||
display: "flex",
|
||||
margin: "-80px 0",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 8px",
|
||||
overflow: "hidden",
|
||||
},
|
||||
})
|
||||
|
||||
const Logo = styled("svg", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transform: "translateY(40%)",
|
||||
opacity: "70%",
|
||||
}
|
||||
})
|
||||
//MaRt@6563
|
||||
export function LibraryRoute() {
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<FullScreen inset="header" >
|
||||
<TitleHeader>
|
||||
<TitleWrapper>
|
||||
<TitleContainer>
|
||||
<Title>
|
||||
Your Steam Library
|
||||
</Title>
|
||||
<Description>
|
||||
Install games directly from your Steam account to your Nestri Machine
|
||||
</Description>
|
||||
</TitleContainer>
|
||||
</TitleWrapper>
|
||||
</TitleHeader>
|
||||
<Container>
|
||||
<Wrapper>
|
||||
<For each={new Array(30)} >
|
||||
{(item, index) => (
|
||||
<SquareImage
|
||||
draggable={false}
|
||||
alt="Assasin's Creed Shadows"
|
||||
src={`/src/assets/games/${index() + 1}.png`} />
|
||||
)}
|
||||
</For>
|
||||
</Wrapper>
|
||||
</Container>
|
||||
<LogoFooter >
|
||||
<Logo viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height={157} width={695} >
|
||||
<g stroke-linecap="round" fill-rule="evenodd" font-size="9pt" stroke="currentColor" stroke-width="0.25mm" fill="currentColor" style="stroke:currentColor;stroke-width:0.25mm;fill:currentColor">
|
||||
<path
|
||||
fill="url(#paint1)"
|
||||
pathLength="1"
|
||||
stroke="url(#paint1)"
|
||||
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="paint1" x1="317.5" x2="314.007" y1="-51.5" y2="126">
|
||||
<stop stop-color="white"></stop>
|
||||
<stop offset="1" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</Logo>
|
||||
</LogoFooter>
|
||||
</FullScreen>
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Header } from "./header"
|
||||
import { theme } from "@nestri/www/ui";
|
||||
import { Text } from "@nestri/www/ui";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { useSteam } from "@nestri/www/providers/steam";
|
||||
import { createEffect, onCleanup } from "solid-js";
|
||||
|
||||
// FIXME: Remove this route, or move it to machines
|
||||
|
||||
// The idea has changed, let the user login to Steam from the / route
|
||||
// Let the machines route remain different from the main page
|
||||
// Why? It becomes much simpler for routing and onboarding, plus how often will you move to the machines route?
|
||||
// Now it will be the home page's problem with making sure the user can download and install games on whatever machine they need/want
|
||||
|
||||
const Root = styled("div", {
|
||||
base: {
|
||||
display: "grid",
|
||||
gridAutoRows: "1fr",
|
||||
position: "relative",
|
||||
gridTemplateRows: "0 auto",
|
||||
backgroundColor: theme.color.background.d200,
|
||||
minHeight: `calc(100vh - ${theme.headerHeight.root})`,
|
||||
gridTemplateColumns: "minmax(24px,1fr) minmax(0,1000px) minmax(24px,1fr)"
|
||||
},
|
||||
});
|
||||
|
||||
const Section = styled("section", {
|
||||
base: {
|
||||
gridColumn: "1/-1",
|
||||
}
|
||||
})
|
||||
|
||||
const TitleHeader = styled("header", {
|
||||
base: {
|
||||
borderBottom: `1px solid ${theme.color.gray.d400}`,
|
||||
color: theme.color.d1000.gray
|
||||
}
|
||||
})
|
||||
|
||||
const TitleWrapper = styled("div", {
|
||||
base: {
|
||||
width: "calc(1000px + calc(2 * 24px))",
|
||||
paddingLeft: "24px",
|
||||
display: "flex",
|
||||
paddingRight: "24px",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
maxWidth: "100%"
|
||||
}
|
||||
})
|
||||
|
||||
const TitleContainer = styled("div", {
|
||||
base: {
|
||||
margin: "40px 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
width: "100%",
|
||||
minWidth: 0
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonContainer = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
margin: "40px 0",
|
||||
}
|
||||
})
|
||||
|
||||
const Title = styled("h1", {
|
||||
base: {
|
||||
lineHeight: "2.5rem",
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
letterSpacing: "-0.069375rem",
|
||||
fontSize: theme.font.size["4xl"],
|
||||
textTransform: "capitalize"
|
||||
}
|
||||
})
|
||||
|
||||
const Description = styled("p", {
|
||||
base: {
|
||||
fontSize: theme.font.size.sm,
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: theme.font.weight.regular,
|
||||
letterSpacing: "initial",
|
||||
color: theme.color.gray.d900
|
||||
}
|
||||
})
|
||||
|
||||
const QRButton = styled("button", {
|
||||
base: {
|
||||
height: 40,
|
||||
borderRadius: theme.borderRadius,
|
||||
backgroundColor: theme.color.d1000.gray,
|
||||
color: theme.color.gray.d100,
|
||||
fontSize: theme.font.size.sm,
|
||||
textWrap: "nowrap",
|
||||
border: "1px solid transparent",
|
||||
padding: `${theme.space[2]} ${theme.space[4]}`,
|
||||
letterSpacing: 0.1,
|
||||
lineHeight: "1.25rem",
|
||||
fontFamily: theme.font.family.body,
|
||||
fontWeight: theme.font.weight.medium,
|
||||
cursor: "pointer",
|
||||
transitionDelay: "0s, 0s",
|
||||
transitionDuration: "0.2s, 0.2s",
|
||||
transitionProperty: "background-color, border",
|
||||
transitionTimingFunction: "ease-out, ease-out",
|
||||
display: "inline-flex",
|
||||
gap: theme.space[2],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
":disabled": {
|
||||
pointerEvents: "none",
|
||||
},
|
||||
":hover": {
|
||||
background: theme.color.hoverColor
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonText = styled("span", {
|
||||
base: {
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
}
|
||||
})
|
||||
|
||||
const Body = styled("div", {
|
||||
base: {
|
||||
padding: "0 24px",
|
||||
width: "calc(1000px + calc(2 * 24px))",
|
||||
minWidth: "calc(100vh - 273px)",
|
||||
margin: "24px auto"
|
||||
}
|
||||
})
|
||||
|
||||
const GamesContainer = styled("div", {
|
||||
base: {
|
||||
background: theme.color.background.d200,
|
||||
padding: "32px 16px",
|
||||
borderRadius: 5,
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
height: "calc(100vh - 300px)",
|
||||
}
|
||||
})
|
||||
|
||||
const EmptyState = styled("div", {
|
||||
base: {
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.space[8],
|
||||
flexDirection: "column"
|
||||
}
|
||||
})
|
||||
|
||||
const SteamLogoContainer = styled("div", {
|
||||
base: {
|
||||
height: 60,
|
||||
width: 60,
|
||||
padding: 4,
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.color.background.d200,
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
}
|
||||
})
|
||||
export function SteamRoute() {
|
||||
const steam = useSteam();
|
||||
|
||||
createEffect(() => {
|
||||
// steam.client.loginStream.connect();
|
||||
|
||||
// Clean up on component unmount
|
||||
// onCleanup(() => {
|
||||
// steam.client.loginStream.disconnect();
|
||||
// });
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Root>
|
||||
<Section>
|
||||
<TitleHeader>
|
||||
<TitleWrapper>
|
||||
<TitleContainer>
|
||||
<Title>
|
||||
Steam Library
|
||||
</Title>
|
||||
<Description>
|
||||
{/* Read and write directly to databases and stores from your projects. */}
|
||||
Install games directly from your Steam account to your Nestri Machine
|
||||
</Description>
|
||||
</TitleContainer>
|
||||
<ButtonContainer>
|
||||
<QRButton>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
|
||||
</svg>
|
||||
<ButtonText>
|
||||
Connect Steam
|
||||
</ButtonText>
|
||||
</QRButton>
|
||||
</ButtonContainer>
|
||||
</TitleWrapper>
|
||||
</TitleHeader>
|
||||
<Body>
|
||||
<GamesContainer>
|
||||
<EmptyState>
|
||||
<SteamLogoContainer>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
|
||||
</svg>
|
||||
</SteamLogoContainer>
|
||||
<Text align="center" style={{ "letter-spacing": "-0.3px" }} size="base" >
|
||||
{/* After connecting your Steam account, your games will appear here */}
|
||||
{/* URL: {steam.client.loginStream.loginUrl()} */}
|
||||
</Text>
|
||||
</EmptyState>
|
||||
</GamesContainer>
|
||||
</Body>
|
||||
</Section>
|
||||
</Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import { useTeam } from "./context";
|
||||
import { EventSource } from 'eventsource'
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { createSignal, onCleanup } from "solid-js";
|
||||
import { createInitializedContext } from "../common/context";
|
||||
|
||||
// Global connection state to prevent multiple instances
|
||||
let globalEventSource: EventSource | null = null;
|
||||
let globalReconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 1;
|
||||
let isConnecting = false;
|
||||
let activeConnection: SteamConnection | null = null;
|
||||
|
||||
// FIXME: The redo button is not working as expected... it does not reinitialise the connection
|
||||
|
||||
// Type definitions for the events
|
||||
interface SteamEventTypes {
|
||||
'connected': { sessionID: string };
|
||||
'challenge': { sessionID: string; url: string };
|
||||
'error': { message: string };
|
||||
'completed': { sessionID: string };
|
||||
}
|
||||
|
||||
// Type for the connection
|
||||
type SteamConnection = {
|
||||
addEventListener: <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => () => void;
|
||||
removeEventListener: <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => void;
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
}
|
||||
|
||||
interface SteamContext {
|
||||
ready: boolean;
|
||||
client: {
|
||||
// SSE connection for login
|
||||
login: {
|
||||
connect: () => Promise<SteamConnection>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Create the initialized context
|
||||
export const { use: useSteam, provider: SteamProvider } = createInitializedContext(
|
||||
"SteamContext",
|
||||
() => {
|
||||
const team = useTeam();
|
||||
const auth = useOpenAuth();
|
||||
|
||||
// Create the HTTP client for regular endpoints
|
||||
const client = {
|
||||
// SSE connection factory for login
|
||||
login: {
|
||||
connect: async (): Promise<SteamConnection> => {
|
||||
// Return existing connection if active
|
||||
if (activeConnection && globalEventSource && globalEventSource.readyState !== 2) {
|
||||
return activeConnection;
|
||||
}
|
||||
|
||||
// Prevent multiple simultaneous connection attempts
|
||||
if (isConnecting) {
|
||||
console.log("Connection attempt already in progress, waiting...");
|
||||
// Wait for existing connection attempt to finish
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!isConnecting && activeConnection) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(activeConnection);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
isConnecting = true;
|
||||
|
||||
const [isConnected, setIsConnected] = createSignal(false);
|
||||
|
||||
// Store event listeners
|
||||
const listeners: Record<string, Array<(data: any) => void>> = {
|
||||
'connected': [],
|
||||
'challenge': [],
|
||||
'error': [],
|
||||
'completed': []
|
||||
};
|
||||
|
||||
// Method to add event listeners
|
||||
const addEventListener = <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => {
|
||||
if (!listeners[event]) {
|
||||
listeners[event] = [];
|
||||
}
|
||||
|
||||
listeners[event].push(callback as any);
|
||||
|
||||
// Return a function to remove this specific listener
|
||||
return () => {
|
||||
removeEventListener(event, callback);
|
||||
};
|
||||
};
|
||||
|
||||
// Method to remove event listeners
|
||||
const removeEventListener = <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => {
|
||||
if (listeners[event]) {
|
||||
const index = listeners[event].indexOf(callback as any);
|
||||
if (index !== -1) {
|
||||
listeners[event].splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notifying listeners safely
|
||||
const notifyListeners = (eventType: string, data: any) => {
|
||||
if (listeners[eventType]) {
|
||||
listeners[eventType].forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in ${eventType} event handler:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize connection
|
||||
const initConnection = async () => {
|
||||
if (globalReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.log(`Maximum reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached. Giving up.`);
|
||||
notifyListeners('error', { message: 'Connection to Steam authentication failed after multiple attempts' });
|
||||
isConnecting = false;
|
||||
disconnect()
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalEventSource) {
|
||||
globalEventSource.close();
|
||||
globalEventSource = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await auth.access();
|
||||
|
||||
// Create new EventSource connection
|
||||
globalEventSource = new EventSource(`${import.meta.env.VITE_API_URL}/steam/login`, {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'x-nestri-team': team().id
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
globalEventSource.onopen = () => {
|
||||
console.log('Connected to Steam login stream');
|
||||
setIsConnected(true);
|
||||
globalReconnectAttempts = 0; // Reset reconnect counter on successful connection
|
||||
isConnecting = false;
|
||||
};
|
||||
|
||||
// Set up event handlers for all specific events
|
||||
['connected', 'challenge', 'completed'].forEach((eventType) => {
|
||||
globalEventSource!.addEventListener(eventType, (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(`Received ${eventType} event:`, data);
|
||||
notifyListeners(eventType, data);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${eventType} event data:`, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle connection errors (this is different from server-sent 'error' events)
|
||||
globalEventSource.onerror = (error) => {
|
||||
console.error('Steam login stream connection error:', error);
|
||||
setIsConnected(false);
|
||||
|
||||
// Close the connection to prevent automatic browser reconnect
|
||||
if (globalEventSource) {
|
||||
globalEventSource.close();
|
||||
}
|
||||
|
||||
// Check if we should attempt to reconnect
|
||||
if (globalReconnectAttempts <= MAX_RECONNECT_ATTEMPTS) {
|
||||
const currentAttempt = globalReconnectAttempts + 1;
|
||||
console.log(`Reconnecting (attempt ${currentAttempt}/${MAX_RECONNECT_ATTEMPTS})...`);
|
||||
globalReconnectAttempts = currentAttempt;
|
||||
|
||||
// Exponential backoff for reconnection
|
||||
const delay = Math.min(1000 * Math.pow(2, globalReconnectAttempts), 30000);
|
||||
setTimeout(initConnection, delay);
|
||||
} else {
|
||||
console.error(`Maximum reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached`);
|
||||
// Notify listeners about connection failure
|
||||
notifyListeners('error', { message: 'Connection to Steam authentication failed after multiple attempts' });
|
||||
disconnect();
|
||||
isConnecting = false;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Steam login stream:', error);
|
||||
setIsConnected(false);
|
||||
isConnecting = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnection function
|
||||
const disconnect = () => {
|
||||
if (globalEventSource) {
|
||||
globalEventSource.close();
|
||||
globalEventSource = null;
|
||||
setIsConnected(false);
|
||||
console.log('Disconnected from Steam login stream');
|
||||
|
||||
// Clear all listeners
|
||||
Object.keys(listeners).forEach(key => {
|
||||
listeners[key] = [];
|
||||
});
|
||||
|
||||
activeConnection = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start the connection immediately
|
||||
await initConnection();
|
||||
|
||||
// Create the connection interface
|
||||
const connection: SteamConnection = {
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
disconnect,
|
||||
isConnected: () => isConnected()
|
||||
};
|
||||
|
||||
// Store the active connection
|
||||
activeConnection = connection;
|
||||
|
||||
// Clean up on context destruction
|
||||
onCleanup(() => {
|
||||
// Instead of disconnecting on cleanup, we'll leave the connection
|
||||
// active for other components to use
|
||||
// Only disconnect if no components are using it
|
||||
if (!isConnected()) {
|
||||
disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
client,
|
||||
ready: true
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useTeam } from "./context"
|
||||
import { createEffect } from "solid-js"
|
||||
// import { createEffect } from "solid-js"
|
||||
import { schema } from "@nestri/zero/schema"
|
||||
import { useQuery } from "@rocicorp/zero/solid"
|
||||
// import { useQuery } from "@rocicorp/zero/solid"
|
||||
import { useOpenAuth } from "@openauthjs/solid"
|
||||
import { Query, Schema, Zero } from "@rocicorp/zero"
|
||||
import { Zero } from "@rocicorp/zero"
|
||||
import { useAccount } from "@nestri/www/providers/account"
|
||||
import { createInitializedContext } from "@nestri/www/common/context"
|
||||
|
||||
export const { use: useZero, provider: ZeroProvider } =
|
||||
createInitializedContext("ZeroContext", () => {
|
||||
const team = useTeam()
|
||||
const auth = useOpenAuth()
|
||||
const account = useAccount()
|
||||
const team = useTeam()
|
||||
const zero = new Zero({
|
||||
schema: schema,
|
||||
auth: () => auth.access(),
|
||||
userID: account.current.email,
|
||||
schema,
|
||||
storageKey: team().id,
|
||||
auth: () => auth.access(),
|
||||
userID: account.current.id,
|
||||
server: import.meta.env.VITE_ZERO_URL,
|
||||
})
|
||||
|
||||
@@ -28,12 +28,12 @@ export const { use: useZero, provider: ZeroProvider } =
|
||||
};
|
||||
});
|
||||
|
||||
export function usePersistentQuery<TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, TReturn>(querySignal: () => Query<TSchema, TTable, TReturn>) {
|
||||
const team = useTeam()
|
||||
//@ts-ignore
|
||||
const q = () => querySignal().where("team_id", "=", team().id).where("time_deleted", "IS", null)
|
||||
createEffect(() => {
|
||||
q().preload()
|
||||
})
|
||||
return useQuery<TSchema, TTable, TReturn>(q)
|
||||
}
|
||||
// export function usePersistentQuery<TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, TReturn>(querySignal: () => Query<TSchema, TTable, TReturn>) {
|
||||
// const team = useTeam()
|
||||
// //@ts-ignore
|
||||
// const q = () => querySignal().where("team_id", "=", team().id).where("time_deleted", "IS", null)
|
||||
// createEffect(() => {
|
||||
// q().preload()
|
||||
// })
|
||||
// return useQuery<TSchema, TTable, TReturn>(q)
|
||||
// }
|
||||
@@ -6,6 +6,7 @@ export const FullScreen = styled("div", {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
position:"relative",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
justifyContent: "center"
|
||||
@@ -15,7 +16,7 @@ export const FullScreen = styled("div", {
|
||||
none: {},
|
||||
header: {
|
||||
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
|
||||
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
|
||||
// minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
table,
|
||||
number,
|
||||
string,
|
||||
boolean,
|
||||
enumeration,
|
||||
createSchema,
|
||||
relationships,
|
||||
@@ -116,6 +117,10 @@ const game_libraries = table("game_libraries")
|
||||
.columns({
|
||||
base_game_id: string(),
|
||||
owner_id: string(),
|
||||
time_acquired: number(),
|
||||
last_played: number(),
|
||||
total_playtime: number(),
|
||||
is_family_shared: boolean(),
|
||||
...timestamps
|
||||
}).primaryKey("base_game_id", "owner_id")
|
||||
|
||||
@@ -145,21 +150,30 @@ export const schema = createSchema({
|
||||
destSchema: members,
|
||||
destField: ["steam_id"],
|
||||
}),
|
||||
friends: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: friends_list,
|
||||
destField: ["steam_id"],
|
||||
}),
|
||||
friendOf: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: friends_list,
|
||||
destField: ["friend_steam_id"],
|
||||
}),
|
||||
libraries: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["owner_id"]
|
||||
})
|
||||
friends: r.many(
|
||||
{
|
||||
sourceField: ["id"],
|
||||
destSchema: friends_list,
|
||||
destField: ["steam_id"],
|
||||
},
|
||||
{
|
||||
sourceField: ["friend_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}
|
||||
),
|
||||
libraryGames: r.many(
|
||||
{
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["owner_id"],
|
||||
},
|
||||
{
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"],
|
||||
}
|
||||
),
|
||||
})),
|
||||
relationships(users, (r) => ({
|
||||
teams: r.many({
|
||||
@@ -207,29 +221,31 @@ export const schema = createSchema({
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
relationships(friends_list, (r) => ({
|
||||
steam: r.one({
|
||||
sourceField: ["steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}),
|
||||
friend: r.one({
|
||||
sourceField: ["friend_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
relationships(base_games, (r) => ({
|
||||
games: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: games,
|
||||
destField: ["base_game_id"]
|
||||
}),
|
||||
libraries: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["base_game_id"]
|
||||
}),
|
||||
libraryOwners: r.many(
|
||||
{
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["base_game_id"],
|
||||
},
|
||||
{
|
||||
sourceField: ["owner_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}
|
||||
),
|
||||
categories: r.many(
|
||||
{
|
||||
sourceField: ["id"],
|
||||
destSchema: games,
|
||||
destField: ["base_game_id"],
|
||||
},
|
||||
{
|
||||
sourceField: ["category_slug", "type"],
|
||||
destSchema: categories,
|
||||
destField: ["slug", "type"],
|
||||
}
|
||||
),
|
||||
images: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: images,
|
||||
@@ -237,45 +253,18 @@ export const schema = createSchema({
|
||||
})
|
||||
})),
|
||||
relationships(categories, (r) => ({
|
||||
games_slug: r.many({
|
||||
sourceField: ["slug"],
|
||||
destSchema: games,
|
||||
destField: ["category_slug"]
|
||||
}),
|
||||
games_type: r.many({
|
||||
sourceField: ["type"],
|
||||
destSchema: games,
|
||||
destField: ["type"]
|
||||
})
|
||||
})),
|
||||
relationships(games, (r) => ({
|
||||
category_slug: r.one({
|
||||
sourceField: ["category_slug"],
|
||||
destSchema: categories,
|
||||
destField: ["slug"],
|
||||
}),
|
||||
category_type: r.one({
|
||||
sourceField: ["type"],
|
||||
destSchema: categories,
|
||||
destField: ["type"],
|
||||
}),
|
||||
base_game: r.one({
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
relationships(game_libraries, (r) => ({
|
||||
base_game: r.one({
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"],
|
||||
}),
|
||||
owner: r.one({
|
||||
sourceField: ["owner_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}),
|
||||
baseGames: r.many(
|
||||
{
|
||||
sourceField: ["slug", "type"],
|
||||
destSchema: games,
|
||||
destField: ["category_slug", "type"],
|
||||
},
|
||||
{
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"],
|
||||
}
|
||||
),
|
||||
})),
|
||||
relationships(images, (r) => ({
|
||||
base_game: r.one({
|
||||
@@ -284,7 +273,45 @@ export const schema = createSchema({
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
//Junction tables
|
||||
relationships(friends_list, (r) => ({
|
||||
steam: r.one({
|
||||
sourceField: ["steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"]
|
||||
}),
|
||||
friend: r.one({
|
||||
sourceField: ["friend_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"]
|
||||
}),
|
||||
})),
|
||||
|
||||
relationships(game_libraries, (r) => ({
|
||||
owner: r.one({
|
||||
sourceField: ["owner_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"]
|
||||
}),
|
||||
baseGame: r.one({
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"]
|
||||
}),
|
||||
})),
|
||||
|
||||
relationships(games, (r) => ({
|
||||
baseGame: r.one({
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"]
|
||||
}),
|
||||
category: r.one({
|
||||
sourceField: ["category_slug", "type"],
|
||||
destSchema: categories,
|
||||
destField: ["slug", "type"]
|
||||
}),
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -321,7 +348,7 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
|
||||
select: [
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'steam_accounts'>) => q.exists("user", (u) => u.where("id", auth.sub)),
|
||||
//Allow friends to view friends steam accounts
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'steam_accounts'>) => q.exists("friends", (u) => u.related("friend", (f) => f.where("user_id", auth.sub))),
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'steam_accounts'>) => q.exists("friends", (u) => u.where("user_id", auth.sub)),
|
||||
//allow other team members to see a user's steam account
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'steam_accounts'>) => q.exists("memberEntries", (u) => u.related("team", (t) => t.related("members", (m) => m.where("user_id", auth.sub)))),
|
||||
]
|
||||
@@ -349,7 +376,7 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
|
||||
//allow team members to see the other members' libraries
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("memberEntries", (f) => f.where("user_id", auth.sub))),
|
||||
//allow friends to see their friends libraries
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("friends", (f) => f.related("friend", (s) => s.where("user_id", auth.sub)))),
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("friends", (f) => f.where("user_id", auth.sub))),
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
4
sst-env.d.ts
vendored
@@ -53,6 +53,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"LibraryQueue": {
|
||||
"type": "sst.aws.Queue"
|
||||
"url": string
|
||||
}
|
||||
"NestriFamilyMonthly": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||