mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
Compare commits
13 Commits
feat/blog
...
feat/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd5608e6e | ||
|
|
a47dc91b22 | ||
|
|
0124af1b70 | ||
|
|
e67a8d2b32 | ||
|
|
8f4bb05143 | ||
|
|
84357ac5bf | ||
|
|
e11012e8d9 | ||
|
|
c0194ecef4 | ||
|
|
ae364f69bd | ||
|
|
d7e6da12ac | ||
|
|
6e19b2e9a0 | ||
|
|
dd20c0049d | ||
|
|
14e4176344 |
@@ -168,8 +168,7 @@ ENV USER="nestri" \
|
||||
USER_PWD="nestri1234" \
|
||||
XDG_RUNTIME_DIR=/run/user/1000 \
|
||||
HOME=/home/nestri \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
NVIDIA_DRIVER_CAPABILITIES=all
|
||||
|
||||
RUN mkdir -p /home/${USER} && \
|
||||
groupadd -g ${GID} ${USER} && \
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { bus } from "./bus";
|
||||
import { auth } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
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", {
|
||||
cluster,
|
||||
@@ -14,8 +13,7 @@ export const apiService = new sst.aws.Service("Api", {
|
||||
bus,
|
||||
auth,
|
||||
postgres,
|
||||
LibraryQueue,
|
||||
steamEncryptionKey,
|
||||
secret.SteamApiKey,
|
||||
secret.PolarSecret,
|
||||
secret.PolarWebhookSecret,
|
||||
secret.NestriFamilyMonthly,
|
||||
@@ -52,7 +50,7 @@ export const apiService = new sst.aws.Service("Api", {
|
||||
transform: {
|
||||
taskDefinition: (args) => {
|
||||
const volumes = $output(args.volumes).apply(v => {
|
||||
const next = [...v, {
|
||||
const next = [...(v || []), {
|
||||
name: "shared-tmp",
|
||||
dockerVolumeConfiguration: {
|
||||
scope: "shared",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { bus } from "./bus";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
import { secret, steamEncryptionKey } from "./secret";
|
||||
|
||||
export const authService = new sst.aws.Service("Auth", {
|
||||
cluster,
|
||||
@@ -13,7 +13,6 @@ export const authService = new sst.aws.Service("Auth", {
|
||||
bus,
|
||||
postgres,
|
||||
secret.PolarSecret,
|
||||
steamEncryptionKey,
|
||||
secret.GithubClientID,
|
||||
secret.DiscordClientID,
|
||||
secret.GithubClientSecret,
|
||||
@@ -56,7 +55,7 @@ export const authService = new sst.aws.Service("Auth", {
|
||||
transform: {
|
||||
taskDefinition: (args) => {
|
||||
const volumes = $output(args.volumes).apply(v => {
|
||||
const next = [...v, {
|
||||
const next = [...(v || []), {
|
||||
name: "shared-tmp",
|
||||
dockerVolumeConfiguration: {
|
||||
scope: "shared",
|
||||
|
||||
60
infra/bus.ts
60
infra/bus.ts
@@ -1,26 +1,70 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { secret } from "./secret";
|
||||
import { storage } from "./storage";
|
||||
// import { email } from "./email";
|
||||
import { postgres } from "./postgres";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
export const dlq = new sst.aws.Queue("Dlq");
|
||||
|
||||
export const retryQueue = new sst.aws.Queue("RetryQueue");
|
||||
|
||||
export const bus = new sst.aws.Bus("Bus");
|
||||
|
||||
bus.subscribe("Event", {
|
||||
export const eventSub = bus.subscribe("Event", {
|
||||
vpc,
|
||||
handler: "packages/functions/src/events/index.handler",
|
||||
link: [
|
||||
// email,
|
||||
postgres,
|
||||
bus,
|
||||
storage,
|
||||
steamEncryptionKey
|
||||
postgres,
|
||||
retryQueue,
|
||||
secret.PolarSecret,
|
||||
secret.SteamApiKey
|
||||
],
|
||||
timeout: "10 minutes",
|
||||
environment: {
|
||||
RETRIES: "2",
|
||||
},
|
||||
memory: "3002 MB",// For faster processing of large(r) images
|
||||
timeout: "10 minutes",
|
||||
});
|
||||
|
||||
new aws.lambda.FunctionEventInvokeConfig("EventConfig", {
|
||||
functionName: $resolve([eventSub.nodes.function.name]).apply(
|
||||
([name]) => name,
|
||||
),
|
||||
maximumRetryAttempts: 1,
|
||||
destinationConfig: {
|
||||
onFailure: {
|
||||
destination: retryQueue.arn,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
retryQueue.subscribe({
|
||||
vpc,
|
||||
handler: "packages/functions/src/queues/retry.handler",
|
||||
timeout: "30 seconds",
|
||||
environment: {
|
||||
RETRIER_QUEUE_URL: retryQueue.url,
|
||||
},
|
||||
link: [
|
||||
dlq,
|
||||
retryQueue,
|
||||
eventSub.nodes.function,
|
||||
],
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
resources: ["*"],
|
||||
actions: ["lambda:GetFunction", "lambda:InvokeFunction"],
|
||||
resources: [
|
||||
$interpolate`arn:aws:lambda:${aws.getRegionOutput().name}:${aws.getCallerIdentityOutput().accountId}:function:*`,
|
||||
],
|
||||
},
|
||||
],
|
||||
transform: {
|
||||
function: {
|
||||
deadLetterConfig: {
|
||||
targetArn: dlq.arn,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
57
infra/images.ts
Normal file
57
infra/images.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { domain } from "./dns";
|
||||
import { storage } from "./storage";
|
||||
|
||||
sst.Linkable.wrap(aws.iam.AccessKey, (resource) => ({
|
||||
properties: {
|
||||
key: resource.id,
|
||||
secret: resource.secret,
|
||||
},
|
||||
}))
|
||||
|
||||
const cache = new sst.cloudflare.Kv("ImageCache");
|
||||
|
||||
const bucket = new sst.cloudflare.Bucket("ImageBucket");
|
||||
|
||||
const lambdaInvokerUser = new aws.iam.User("ImageIAMUser", {
|
||||
name: `${$app.name}-${$app.stage}-ImageIAMUser`,
|
||||
forceDestroy: true
|
||||
});
|
||||
|
||||
const imageProcessorFunction = new sst.aws.Function("ImageProcessor",
|
||||
{
|
||||
memory: "1024 MB",
|
||||
link: [storage],
|
||||
timeout: "30 seconds",
|
||||
nodejs: { install: ["sharp"] },
|
||||
handler: "packages/functions/src/images/processor.handler",
|
||||
},
|
||||
);
|
||||
|
||||
new aws.iam.UserPolicy("InvokeLambdaPolicy", {
|
||||
user: lambdaInvokerUser.name,
|
||||
policy: $output({
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: ["lambda:InvokeFunction"],
|
||||
Resource: imageProcessorFunction.arn,
|
||||
},
|
||||
],
|
||||
}).apply(JSON.stringify),
|
||||
});
|
||||
|
||||
const accessKey = new aws.iam.AccessKey("ImageInvokerAccessKey", {
|
||||
user: lambdaInvokerUser.name,
|
||||
});
|
||||
|
||||
export const imageCdn = new sst.cloudflare.Worker("ImageCDN", {
|
||||
url: true,
|
||||
domain: "cdn." + domain,
|
||||
link: [bucket, cache, imageProcessorFunction, accessKey],
|
||||
handler: "packages/functions/src/images/index.ts",
|
||||
});
|
||||
|
||||
export const outputs = {
|
||||
cdn: imageCdn.url
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { isPermanentStage } from "./stage";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
// TODO: Add a dev db to use, this will help with running zero locally... and testing it
|
||||
export const postgres = new sst.aws.Aurora("Database", {
|
||||
vpc,
|
||||
engine: "postgres",
|
||||
scaling: isPermanentStage
|
||||
? undefined
|
||||
: {
|
||||
min: "0 ACU",
|
||||
max: "1 ACU",
|
||||
},
|
||||
scaling: {
|
||||
min: "0 ACU",
|
||||
max: "1 ACU",
|
||||
},
|
||||
transform: {
|
||||
clusterParameterGroup: {
|
||||
parameters: [
|
||||
@@ -42,7 +37,7 @@ export const postgres = new sst.aws.Aurora("Database", {
|
||||
|
||||
|
||||
new sst.x.DevCommand("Studio", {
|
||||
link: [postgres, steamEncryptionKey],
|
||||
link: [postgres],
|
||||
dev: {
|
||||
command: "bun db:dev studio",
|
||||
directory: "packages/core",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const secret = {
|
||||
PolarSecret: new sst.Secret("PolarSecret", process.env.POLAR_API_KEY),
|
||||
SteamApiKey: new sst.Secret("SteamApiKey"),
|
||||
GithubClientID: new sst.Secret("GithubClientID"),
|
||||
DiscordClientID: new sst.Secret("DiscordClientID"),
|
||||
PolarWebhookSecret: new sst.Secret("PolarWebhookSecret"),
|
||||
@@ -15,16 +16,3 @@ export const secret = {
|
||||
};
|
||||
|
||||
export const allSecrets = Object.values(secret);
|
||||
|
||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}));
|
||||
|
||||
export const steamEncryptionKey = new random.RandomString(
|
||||
"SteamEncryptionKey",
|
||||
{
|
||||
length: 32,
|
||||
},
|
||||
);
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
],
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const zeroEnv = {
|
||||
ZERO_LITESTREAM_RESTORE_PARALLELISM: "64",
|
||||
ZERO_APP_ID: $app.stage,
|
||||
ZERO_AUTH_JWKS_URL: $interpolate`${auth.url}/.well-known/jwks.json`,
|
||||
ZERO_INITIAL_SYNC_ROW_BATCH_SIZE: "30000",
|
||||
NODE_OPTIONS: "--max-old-space-size=8192",
|
||||
...($dev
|
||||
? {
|
||||
@@ -145,9 +146,9 @@ export const zero = new sst.aws.Service("Zero", {
|
||||
ZERO_NUM_SYNC_WORKERS: "1",
|
||||
}
|
||||
: {
|
||||
ZERO_CHANGE_STREAMER_URI: replicationManager.url.apply((val) =>
|
||||
ZERO_CHANGE_STREAMER_URI: replicationManager?.url.apply((val) =>
|
||||
val.replace("http://", "ws://"),
|
||||
),
|
||||
) ?? "",
|
||||
ZERO_UPSTREAM_MAX_CONNS: "15",
|
||||
ZERO_CVR_MAX_CONNS: "160",
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "4.20240821.1",
|
||||
"@pulumi/pulumi": "^3.134.0",
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/aws-lambda": "8.10.147",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.4.5"
|
||||
@@ -18,7 +19,6 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@rocicorp/zero": "0.20.2025050901",
|
||||
"steam-session": "1.9.3"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
@@ -30,7 +30,6 @@
|
||||
"core-js-pure",
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"@rocicorp/zero-sqlite3",
|
||||
"workerd"
|
||||
],
|
||||
"workspaces": [
|
||||
@@ -38,6 +37,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"sharp": "^0.34.2",
|
||||
"sst": "^3.11.21"
|
||||
}
|
||||
}
|
||||
23
packages/core/migrations/0020_vengeful_wallop.sql
Normal file
23
packages/core/migrations/0020_vengeful_wallop.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE "steam_account_credentials" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "steam_account_credentials" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
|
||||
ALTER TABLE "teams" RENAME COLUMN "owner_id" TO "owner_steam_id";--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" DROP CONSTRAINT "idx_steam_username";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP CONSTRAINT "teams_owner_id_users_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP CONSTRAINT "teams_slug_steam_accounts_username_fk";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX "idx_team_slug";--> statement-breakpoint
|
||||
DROP INDEX "idx_game_libraries_owner_id";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP CONSTRAINT "game_libraries_base_game_id_owner_id_pk";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ALTER COLUMN "last_played" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_owner_steam_id_pk" PRIMARY KEY("base_game_id","owner_steam_id");--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_steam_id_steam_accounts_id_fk" FOREIGN KEY ("owner_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_steam_id");--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP COLUMN "time_acquired";--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" DROP COLUMN "is_family_shared";--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" DROP COLUMN "username";--> statement-breakpoint
|
||||
ALTER TABLE "teams" DROP COLUMN "slug";
|
||||
2
packages/core/migrations/0021_real_skreet.sql
Normal file
2
packages/core/migrations/0021_real_skreet.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE "public"."category_type" ADD VALUE 'category';--> statement-breakpoint
|
||||
ALTER TYPE "public"."category_type" ADD VALUE 'franchise';
|
||||
6
packages/core/migrations/0022_clean_living_lightning.sql
Normal file
6
packages/core/migrations/0022_clean_living_lightning.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."category_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer', 'categorie', 'franchise');--> statement-breakpoint
|
||||
ALTER TABLE "public"."categories" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";--> statement-breakpoint
|
||||
ALTER TABLE "public"."games" ALTER COLUMN "type" SET DATA TYPE "public"."category_type" USING "type"::"public"."category_type";
|
||||
2
packages/core/migrations/0023_flawless_steel_serpent.sql
Normal file
2
packages/core/migrations/0023_flawless_steel_serpent.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ADD COLUMN "links" text[];
|
||||
1
packages/core/migrations/0024_damp_cerise.sql
Normal file
1
packages/core/migrations/0024_damp_cerise.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "links" SET DATA TYPE json;
|
||||
3
packages/core/migrations/0025_bitter_jack_flag.sql
Normal file
3
packages/core/migrations/0025_bitter_jack_flag.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE "members" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "teams" CASCADE;--> statement-breakpoint
|
||||
DROP TYPE "public"."member_role";
|
||||
1158
packages/core/migrations/meta/0020_snapshot.json
Normal file
1158
packages/core/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1160
packages/core/migrations/meta/0021_snapshot.json
Normal file
1160
packages/core/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1160
packages/core/migrations/meta/0022_snapshot.json
Normal file
1160
packages/core/migrations/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1166
packages/core/migrations/meta/0023_snapshot.json
Normal file
1166
packages/core/migrations/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1166
packages/core/migrations/meta/0024_snapshot.json
Normal file
1166
packages/core/migrations/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
930
packages/core/migrations/meta/0025_snapshot.json
Normal file
930
packages/core/migrations/meta/0025_snapshot.json
Normal file
@@ -0,0 +1,930 @@
|
||||
{
|
||||
"id": "735d315b-40e1-46c1-814d-0fd3619b65de",
|
||||
"prevId": "d35aa09b-5739-46a5-86f3-3050913dc2f7",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.base_games": {
|
||||
"name": "base_games",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"links": {
|
||||
"name": "links",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"release_date": {
|
||||
"name": "release_date",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"primary_genre": {
|
||||
"name": "primary_genre",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"controller_support": {
|
||||
"name": "controller_support",
|
||||
"type": "controller_support",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"compatibility": {
|
||||
"name": "compatibility",
|
||||
"type": "compatibility",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"score": {
|
||||
"name": "score",
|
||||
"type": "numeric(2, 1)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_base_games_slug": {
|
||||
"name": "idx_base_games_slug",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.categories": {
|
||||
"name": "categories",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "category_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_categories_type": {
|
||||
"name": "idx_categories_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"categories_slug_type_pk": {
|
||||
"name": "categories_slug_type_pk",
|
||||
"columns": [
|
||||
"slug",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friends_list": {
|
||||
"name": "friends_list",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_steam_id": {
|
||||
"name": "friend_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_friends_list_friend_steam_id": {
|
||||
"name": "idx_friends_list_friend_steam_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "friend_steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"friends_list_steam_id_steam_accounts_id_fk": {
|
||||
"name": "friends_list_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friends_list_friend_steam_id_steam_accounts_id_fk": {
|
||||
"name": "friends_list_friend_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"friend_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"friends_list_steam_id_friend_steam_id_pk": {
|
||||
"name": "friends_list_steam_id_friend_steam_id_pk",
|
||||
"columns": [
|
||||
"steam_id",
|
||||
"friend_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.games": {
|
||||
"name": "games",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"category_slug": {
|
||||
"name": "category_slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "category_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_games_category_slug": {
|
||||
"name": "idx_games_category_slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "category_slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_games_category_type": {
|
||||
"name": "idx_games_category_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_games_category_slug_type": {
|
||||
"name": "idx_games_category_slug_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "category_slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"games_base_game_id_base_games_id_fk": {
|
||||
"name": "games_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "games",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"games_categories_fkey": {
|
||||
"name": "games_categories_fkey",
|
||||
"tableFrom": "games",
|
||||
"tableTo": "categories",
|
||||
"columnsFrom": [
|
||||
"category_slug",
|
||||
"type"
|
||||
],
|
||||
"columnsTo": [
|
||||
"slug",
|
||||
"type"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"games_base_game_id_category_slug_type_pk": {
|
||||
"name": "games_base_game_id_category_slug_type_pk",
|
||||
"columns": [
|
||||
"base_game_id",
|
||||
"category_slug",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.images": {
|
||||
"name": "images",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "image_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_hash": {
|
||||
"name": "image_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"dimensions": {
|
||||
"name": "dimensions",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"extracted_color": {
|
||||
"name": "extracted_color",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_images_type": {
|
||||
"name": "idx_images_type",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_images_game_id": {
|
||||
"name": "idx_images_game_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "base_game_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"images_base_game_id_base_games_id_fk": {
|
||||
"name": "images_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "images",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"images_image_hash_type_base_game_id_position_pk": {
|
||||
"name": "images_image_hash_type_base_game_id_position_pk",
|
||||
"columns": [
|
||||
"image_hash",
|
||||
"type",
|
||||
"base_game_id",
|
||||
"position"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.game_libraries": {
|
||||
"name": "game_libraries",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"base_game_id": {
|
||||
"name": "base_game_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_steam_id": {
|
||||
"name": "owner_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_played": {
|
||||
"name": "last_played",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"total_playtime": {
|
||||
"name": "total_playtime",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_game_libraries_owner_id": {
|
||||
"name": "idx_game_libraries_owner_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "owner_steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"game_libraries_base_game_id_base_games_id_fk": {
|
||||
"name": "game_libraries_base_game_id_base_games_id_fk",
|
||||
"tableFrom": "game_libraries",
|
||||
"tableTo": "base_games",
|
||||
"columnsFrom": [
|
||||
"base_game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"game_libraries_owner_steam_id_steam_accounts_id_fk": {
|
||||
"name": "game_libraries_owner_steam_id_steam_accounts_id_fk",
|
||||
"tableFrom": "game_libraries",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"owner_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"game_libraries_base_game_id_owner_steam_id_pk": {
|
||||
"name": "game_libraries_base_game_id_owner_steam_id_pk",
|
||||
"columns": [
|
||||
"base_game_id",
|
||||
"owner_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam_accounts": {
|
||||
"name": "steam_accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "steam_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_synced_at": {
|
||||
"name": "last_synced_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"real_name": {
|
||||
"name": "real_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"member_since": {
|
||||
"name": "member_since",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"profile_url": {
|
||||
"name": "profile_url",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"limitations": {
|
||||
"name": "limitations",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_accounts_user_id_users_id_fk": {
|
||||
"name": "steam_accounts_user_id_users_id_fk",
|
||||
"tableFrom": "steam_accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_login": {
|
||||
"name": "last_login",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_user_email": {
|
||||
"name": "idx_user_email",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.compatibility": {
|
||||
"name": "compatibility",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"high",
|
||||
"mid",
|
||||
"low",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"public.controller_support": {
|
||||
"name": "controller_support",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"full",
|
||||
"partial",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"public.category_type": {
|
||||
"name": "category_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"tag",
|
||||
"genre",
|
||||
"publisher",
|
||||
"developer",
|
||||
"categorie",
|
||||
"franchise"
|
||||
]
|
||||
},
|
||||
"public.image_type": {
|
||||
"name": "image_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"heroArt",
|
||||
"icon",
|
||||
"logo",
|
||||
"banner",
|
||||
"poster",
|
||||
"boxArt",
|
||||
"screenshot",
|
||||
"backdrop"
|
||||
]
|
||||
},
|
||||
"public.steam_status": {
|
||||
"name": "steam_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"online",
|
||||
"offline",
|
||||
"dnd",
|
||||
"playing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,48 @@
|
||||
"when": 1747202158003,
|
||||
"tag": "0019_charming_namorita",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1747795508868,
|
||||
"tag": "0020_vengeful_wallop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1747975397543,
|
||||
"tag": "0021_real_skreet",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1748099972605,
|
||||
"tag": "0022_clean_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1748411845939,
|
||||
"tag": "0023_flawless_steel_serpent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1748414049463,
|
||||
"tag": "0024_damp_cerise",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1748845818197,
|
||||
"tag": "0025_bitter_jack_flag",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"mqtt": "^5.10.3",
|
||||
@@ -42,6 +43,7 @@
|
||||
"postgres": "^3.4.5",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*"
|
||||
"steam-session": "*",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
import { User } from "../user";
|
||||
import { Team } from "../team";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
@@ -9,26 +9,26 @@ export namespace Account {
|
||||
export const Info =
|
||||
User.Info
|
||||
.extend({
|
||||
teams: Team.Info
|
||||
profiles: Steam.Info
|
||||
.array()
|
||||
.openapi({
|
||||
description: "The teams that this user is part of",
|
||||
example: [Examples.Team]
|
||||
description: "The Steam accounts this user owns",
|
||||
example: [Examples.SteamAccount]
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Account",
|
||||
description: "Represents an account's information stored on Nestri",
|
||||
example: { ...Examples.User, teams: [Examples.Team] },
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] },
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async (): Promise<Info> => {
|
||||
const [userResult, teamsResult] =
|
||||
const [userResult, steamResult] =
|
||||
await Promise.allSettled([
|
||||
User.fromID(Actor.userID()),
|
||||
Team.list()
|
||||
Steam.list()
|
||||
])
|
||||
|
||||
if (userResult.status === "rejected" || !userResult.value)
|
||||
@@ -40,7 +40,7 @@ export namespace Account {
|
||||
|
||||
return {
|
||||
...userResult.value,
|
||||
teams: teamsResult.status === "rejected" ? [] : teamsResult.value
|
||||
profiles: steamResult.status === "rejected" ? [] : steamResult.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ export namespace Actor {
|
||||
};
|
||||
}
|
||||
|
||||
export interface System {
|
||||
type: "system";
|
||||
export interface Steam {
|
||||
type: "steam";
|
||||
properties: {
|
||||
teamID: string;
|
||||
steamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export namespace Actor {
|
||||
properties: {
|
||||
userID: string;
|
||||
steamID: string;
|
||||
teamID: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +40,7 @@ export namespace Actor {
|
||||
properties: {};
|
||||
}
|
||||
|
||||
export type Info = User | Public | Token | System | Machine;
|
||||
export type Info = User | Public | Token | Machine | Steam;
|
||||
|
||||
export const Context = createContext<Info>();
|
||||
|
||||
|
||||
@@ -3,15 +3,18 @@ import { timestamps, utc } from "../drizzle/types";
|
||||
import { json, numeric, pgEnum, pgTable, text, unique, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CompatibilityEnum = pgEnum("compatibility", ["high", "mid", "low", "unknown"])
|
||||
export const ControllerEnum = pgEnum("controller_support", ["full","partial", "unknown"])
|
||||
export const ControllerEnum = pgEnum("controller_support", ["full", "partial", "unknown"])
|
||||
|
||||
export const Size =
|
||||
z.object({
|
||||
downloadSize: z.number().positive().int(),
|
||||
sizeOnDisk: z.number().positive().int()
|
||||
})
|
||||
});
|
||||
|
||||
export type Size = z.infer<typeof Size>
|
||||
export const Links = z.string().array();
|
||||
|
||||
export type Size = z.infer<typeof Size>;
|
||||
export type Links = z.infer<typeof Links>;
|
||||
|
||||
export const baseGamesTable = pgTable(
|
||||
"base_games",
|
||||
@@ -20,12 +23,13 @@ export const baseGamesTable = pgTable(
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
links: json("links").$type<Links>(),
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
releaseDate: utc("release_date").notNull(),
|
||||
size: json("size").$type<Size>().notNull(),
|
||||
description: text("description").notNull(),
|
||||
primaryGenre: text("primary_genre"),
|
||||
controllerSupport: ControllerEnum("controller_support").notNull(),
|
||||
compatibility: CompatibilityEnum("compatibility").notNull().default("unknown"),
|
||||
|
||||
@@ -1,13 +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 { 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";
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum, Links } from "./base-game.sql";
|
||||
|
||||
export namespace BaseGame {
|
||||
export const Info = z.object({
|
||||
@@ -31,7 +30,7 @@ export namespace BaseGame {
|
||||
description: "The initial public release date of the game on Steam",
|
||||
example: Examples.BaseGame.releaseDate
|
||||
}),
|
||||
description: z.string().openapi({
|
||||
description: z.string().nullable().openapi({
|
||||
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
|
||||
example: Examples.BaseGame.description
|
||||
}),
|
||||
@@ -39,6 +38,12 @@ export namespace BaseGame {
|
||||
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews",
|
||||
example: Examples.BaseGame.score
|
||||
}),
|
||||
links: Links
|
||||
.nullable()
|
||||
.openapi({
|
||||
description: "The social links of this game",
|
||||
example: Examples.BaseGame.links
|
||||
}),
|
||||
primaryGenre: z.string().nullable().openapi({
|
||||
description: "The main category or genre that best represents the game's content and gameplay style",
|
||||
example: Examples.BaseGame.primaryGenre
|
||||
@@ -50,7 +55,7 @@ export namespace BaseGame {
|
||||
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
|
||||
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
|
||||
example: Examples.BaseGame.compatibility
|
||||
})
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "BaseGame",
|
||||
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata",
|
||||
@@ -61,9 +66,27 @@ export namespace BaseGame {
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_game.added",
|
||||
"new_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
url: z.string().url(),
|
||||
type: z.enum(ImageTypeEnum.enumValues)
|
||||
}),
|
||||
),
|
||||
NewBoxArt: createEvent(
|
||||
"new_box_art_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
logoUrl: z.string().url(),
|
||||
backgroundUrl: z.string().url(),
|
||||
}),
|
||||
),
|
||||
NewHeroArt: createEvent(
|
||||
"new_hero_art_image.save",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
backdropUrl: z.string().url(),
|
||||
screenshots: z.string().url().array(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -72,6 +95,21 @@ export namespace BaseGame {
|
||||
Info,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(baseGamesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(baseGamesTable.id, input.id),
|
||||
isNull(baseGamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.id
|
||||
|
||||
await tx
|
||||
.insert(baseGamesTable)
|
||||
.values(input)
|
||||
@@ -82,10 +120,6 @@ export namespace BaseGame {
|
||||
}
|
||||
})
|
||||
|
||||
await afterTx(async () => {
|
||||
await bus.publish(Resource.Bus, Events.New, { appID: input.id })
|
||||
})
|
||||
|
||||
return input.id
|
||||
})
|
||||
)
|
||||
@@ -116,6 +150,7 @@ export namespace BaseGame {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
size: input.size,
|
||||
links: input.links,
|
||||
score: input.score,
|
||||
description: input.description,
|
||||
releaseDate: input.releaseDate,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { index, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer"])
|
||||
// Intentional grammatical error on category
|
||||
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer", "categorie", "franchise"])
|
||||
|
||||
export const categoriesTable = pgTable(
|
||||
"categories",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { categoriesTable } from "./categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
|
||||
export namespace Categories {
|
||||
|
||||
@@ -36,7 +36,16 @@ export namespace Categories {
|
||||
genres: Category.array().openapi({
|
||||
description: "Primary classification categories that define the game's style and type of gameplay",
|
||||
example: Examples.Categories.genres
|
||||
})
|
||||
}),
|
||||
categories: Category.array().openapi({
|
||||
description: "Primary classification categories that define the game's categorisation on Steam",
|
||||
example: Examples.Categories.genres
|
||||
}),
|
||||
franchises: Category.array().openapi({
|
||||
description: "The franchise this game belongs belongs to on Steam",
|
||||
example: Examples.Categories.genres
|
||||
}),
|
||||
|
||||
}).openapi({
|
||||
ref: "Categories",
|
||||
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification",
|
||||
@@ -111,7 +120,9 @@ export namespace Categories {
|
||||
tags: [],
|
||||
genres: [],
|
||||
publishers: [],
|
||||
developers: []
|
||||
developers: [],
|
||||
categories: [],
|
||||
franchises: []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,159 +1,172 @@
|
||||
import type {
|
||||
Shot,
|
||||
AppInfo,
|
||||
GameTagsResponse,
|
||||
SteamApiResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
ImageInfo,
|
||||
ImageType,
|
||||
Shot
|
||||
SteamAccount,
|
||||
GameTagsResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
SteamOwnedGamesResponse,
|
||||
SteamPlayerBansResponse,
|
||||
SteamFriendsListResponse,
|
||||
SteamPlayerSummaryResponse,
|
||||
SteamStoreResponse,
|
||||
} from "./types";
|
||||
import { z } from "zod";
|
||||
import pLimit from 'p-limit';
|
||||
import SteamID from "steamid";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Steam } from "./steam";
|
||||
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
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
|
||||
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`)
|
||||
z.string(),
|
||||
async (steamID) =>
|
||||
await Utils.fetchApi<SteamOwnedGamesResponse>(`https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&include_appinfo=1&format=json&include_played_free_games=1&skip_unvetted_apps=0`)
|
||||
)
|
||||
|
||||
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);
|
||||
}
|
||||
z.string(),
|
||||
async (steamID) =>
|
||||
await Utils.fetchApi<SteamFriendsListResponse>(`https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?key=${Resource.SteamApiKey.value}&steamid=${steamID}&relationship=friend`)
|
||||
);
|
||||
|
||||
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}`)
|
||||
z.string().array(),
|
||||
async (steamIDs) => {
|
||||
const [userInfo, banInfo, profileInfo] = await Promise.all([
|
||||
Utils.fetchApi<SteamPlayerSummaryResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
|
||||
Utils.fetchApi<SteamPlayerBansResponse>(`https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key=${Resource.SteamApiKey.value}&steamids=${steamIDs.join(",")}`),
|
||||
Utils.fetchProfilesInfo(steamIDs)
|
||||
])
|
||||
|
||||
// Create a map of bans by steamID for fast lookup
|
||||
const bansBySteamID = new Map(
|
||||
banInfo.players.map((b) => [b.SteamId, b])
|
||||
);
|
||||
|
||||
// Map userInfo.players to your desired output using Promise.allSettled
|
||||
// to prevent one error from closing down the whole pipeline
|
||||
const steamAccounts = await Promise.allSettled(
|
||||
userInfo.response.players.map(async (player) => {
|
||||
const ban = bansBySteamID.get(player.steamid);
|
||||
const info = profileInfo.get(player.steamid);
|
||||
|
||||
if (!info) {
|
||||
throw new Error(`[userInfo] profile info missing for ${player.steamid}`)
|
||||
}
|
||||
|
||||
if ('error' in info) {
|
||||
throw new Error(`error handling profile info for: ${player.steamid}:${info.error}`)
|
||||
} else {
|
||||
resolve(user)
|
||||
return {
|
||||
id: player.steamid,
|
||||
name: player.personaname,
|
||||
realName: player.realname ?? null,
|
||||
steamMemberSince: new Date(player.timecreated * 1000),
|
||||
avatarHash: player.avatarhash,
|
||||
limitations: {
|
||||
isLimited: info.isLimited,
|
||||
privacyState: info.privacyState,
|
||||
isVacBanned: ban?.VACBanned ?? false,
|
||||
tradeBanState: ban?.EconomyBan ?? "none",
|
||||
visibilityState: player.communityvisibilitystate,
|
||||
},
|
||||
lastSyncedAt: new Date(),
|
||||
profileUrl: player.profileurl,
|
||||
};
|
||||
}
|
||||
})
|
||||
}) as Promise<CSteamUser>
|
||||
)
|
||||
);
|
||||
|
||||
steamAccounts
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[userInfo] failed:', (result as PromiseRejectedResult).reason))
|
||||
|
||||
return steamAccounts.filter(result => result.status === "fulfilled").map(result => (result as PromiseFulfilledResult<SteamAccount>).value)
|
||||
})
|
||||
|
||||
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`
|
||||
),
|
||||
]);
|
||||
try {
|
||||
const info = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<SteamStoreResponse>(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?key=${Resource.SteamApiKey.value}&input_json={"ids":[{"appid":"${appid}"}],"context":{"language":"english","country_code":"US","steam_realm":"1"},"data_request":{"include_assets":true,"include_release":true,"include_platforms":true,"include_all_purchase_options":true,"include_screenshots":true,"include_trailers":true,"include_ratings":true,"include_tag_count":"40","include_reviews":true,"include_basic_info":true,"include_supported_languages":true,"include_full_description":true,"include_included_items":true,"include_assets_without_overrides":true,"apply_user_filters":true,"include_links":true}}`),
|
||||
]);
|
||||
|
||||
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 cmd = info[0].data[appid]
|
||||
const store = info[1].response.store_items[0]
|
||||
|
||||
const controllerTag = game.common.controller_support ?
|
||||
Utils.createTag(`${Utils.capitalise(game.common.controller_support)} Controller Support`) :
|
||||
Utils.createTag(`Unknown Controller Support`)
|
||||
if (!cmd) {
|
||||
throw new Error(`App data not found for appid: ${appid}`)
|
||||
}
|
||||
|
||||
const compatibilityTag = Utils.createTag(`${Utils.capitalise(Utils.compatibilityType(game.common.steam_deck_compatibility?.category))} Compatibility`)
|
||||
if (!store || store.success !== 1) {
|
||||
throw new Error(`Could not get store information or appid: ${appid}`)
|
||||
}
|
||||
|
||||
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
|
||||
),
|
||||
};
|
||||
const tags = store.tagids
|
||||
.map(id => Steam.tags[id.toString() as keyof typeof Steam.tags])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
return appInfo
|
||||
const publishers = store.basic_info.publishers
|
||||
.map(i => i.name)
|
||||
|
||||
const developers = store.basic_info.developers
|
||||
.map(i => i.name)
|
||||
|
||||
const franchises = store.basic_info.franchises
|
||||
?.map(i => i.name)
|
||||
|
||||
const genres = cmd?.common.genres &&
|
||||
Object.keys(cmd?.common.genres)
|
||||
.map(id => Steam.genres[id.toString() as keyof typeof Steam.genres])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const categories = [
|
||||
...(store.categories?.controller_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? []),
|
||||
...(store.categories?.supported_player_categoryids?.map(i => Steam.categories[i.toString() as keyof typeof Steam.categories]) ?? [])
|
||||
].filter((name): name is string => typeof name === 'string')
|
||||
|
||||
const assetUrls = Utils.getAssetUrls(cmd?.common.library_assets_full, appid, cmd?.common.header_image.english);
|
||||
|
||||
const screenshots = store.screenshots.all_ages_screenshots?.map(i => `https://shared.cloudflare.steamstatic.com/store_item_assets/${i.filename}`) ?? [];
|
||||
|
||||
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${cmd?.common.icon}.jpg`;
|
||||
|
||||
const data: AppInfo = {
|
||||
id: appid,
|
||||
name: cmd?.common.name.trim(),
|
||||
tags: Utils.createType(tags, "tag"),
|
||||
images: { screenshots, icon, ...assetUrls },
|
||||
size: Utils.getPublicDepotSizes(cmd?.depots!),
|
||||
slug: Utils.createSlug(cmd?.common.name.trim()),
|
||||
publishers: Utils.createType(publishers, "publisher"),
|
||||
developers: Utils.createType(developers, "developer"),
|
||||
categories: Utils.createType(categories, "categorie"),
|
||||
links: store.links ? store.links.map(i => i.url) : null,
|
||||
genres: genres ? Utils.createType(genres, "genre") : [],
|
||||
franchises: franchises ? Utils.createType(franchises, "franchise") : [],
|
||||
description: store.basic_info.short_description ? Utils.cleanDescription(store.basic_info.short_description) : null,
|
||||
controllerSupport: cmd?.common.controller_support ?? "unknown" as any,
|
||||
releaseDate: new Date(Number(cmd?.common.steam_release_date) * 1000),
|
||||
primaryGenre: !!cmd?.common.primary_genre && Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] ? Steam.genres[cmd?.common.primary_genre as keyof typeof Steam.genres] : null,
|
||||
compatibility: store?.platforms.steam_os_compat_category ? Utils.compatibilityType(store?.platforms.steam_os_compat_category.toString() as any).toLowerCase() : "unknown" as any,
|
||||
score: Utils.estimateRatingFromSummary(store.reviews.summary_filtered.review_count, store.reviews.summary_filtered.percent_positive)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.log(`Error handling: ${appid}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const getImages = fn(
|
||||
export const getImageUrls = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [appData, details] = await Promise.all([
|
||||
@@ -167,18 +180,49 @@ export namespace Client {
|
||||
if (!game) throw new Error('Game info missing');
|
||||
|
||||
// 2. Prepare URLs
|
||||
const screenshotUrls = Utils.getScreenshotUrls(details.rgScreenshots || []);
|
||||
const screenshots = 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`;
|
||||
const icon = `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);
|
||||
return { screenshots, icon, ...assetUrls }
|
||||
}
|
||||
)
|
||||
|
||||
// 3. Download screenshot buffers in parallel
|
||||
export const getImageInfo = fn(
|
||||
z.object({
|
||||
type: z.enum(ImageTypeEnum.enumValues),
|
||||
url: z.string()
|
||||
}),
|
||||
async (input) =>
|
||||
Utils.fetchBuffer(input.url)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: input.url, type: input.type } as ImageInfo))
|
||||
)
|
||||
|
||||
export const createBoxArt = fn(
|
||||
z.object({
|
||||
backgroundUrl: z.string(),
|
||||
logoUrl: z.string(),
|
||||
}),
|
||||
async (input) =>
|
||||
Utils.createBoxArtBuffer(input.logoUrl, input.backgroundUrl)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
|
||||
)
|
||||
|
||||
export const createHeroArt = fn(
|
||||
z.object({
|
||||
screenshots: z.string().array(),
|
||||
backdropUrl: z.string()
|
||||
}),
|
||||
async (input) => {
|
||||
// Download screenshot buffers in parallel
|
||||
const shots: Shot[] = await Promise.all(
|
||||
screenshotUrls.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
|
||||
input.screenshots.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
|
||||
);
|
||||
|
||||
const baselineBuffer = await Utils.fetchBuffer(input.backdropUrl);
|
||||
|
||||
// 4. Score screenshots (or pick single)
|
||||
const scores =
|
||||
shots.length === 1
|
||||
@@ -204,37 +248,69 @@ export namespace Client {
|
||||
);
|
||||
}
|
||||
|
||||
// 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)
|
||||
const settled = await Promise.allSettled(tasks);
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getImages] failed:", (r as PromiseRejectedResult).reason));
|
||||
.forEach(r => console.warn("[getHeroArt] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
// 6. Await all and return
|
||||
// Await all and return
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<ImageInfo>).value)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Verifies a Steam OpenID response by sending a request back to Steam
|
||||
* with mode=check_authentication
|
||||
*/
|
||||
export async function verifyOpenIDResponse(params: URLSearchParams): Promise<string | null> {
|
||||
try {
|
||||
// Create a new URLSearchParams with all the original parameters
|
||||
const verificationParams = new URLSearchParams();
|
||||
|
||||
// Copy all parameters from the original request
|
||||
for (const [key, value] of params.entries()) {
|
||||
verificationParams.append(key, value);
|
||||
}
|
||||
|
||||
// Change mode to check_authentication for verification
|
||||
verificationParams.set('openid.mode', 'check_authentication');
|
||||
|
||||
// Send verification request to Steam
|
||||
const verificationResponse = await fetch('https://steamcommunity.com/openid/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: verificationParams.toString()
|
||||
});
|
||||
|
||||
const responseText = await verificationResponse.text();
|
||||
|
||||
// Check if verification was successful
|
||||
if (!responseText.includes('is_valid:true')) {
|
||||
console.error('OpenID verification failed: Invalid response from Steam', responseText);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract steamID from the claimed_id
|
||||
const claimedId = params.get('openid.claimed_id');
|
||||
if (!claimedId) {
|
||||
console.error('OpenID verification failed: Missing claimed_id');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the Steam ID from the claimed_id
|
||||
const steamID = claimedId.split('/').pop();
|
||||
if (!steamID || !/^\d+$/.test(steamID)) {
|
||||
console.error('OpenID verification failed: Invalid steamID format', steamID);
|
||||
return null;
|
||||
}
|
||||
|
||||
return steamID;
|
||||
} catch (error) {
|
||||
console.error('OpenID verification error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
544
packages/core/src/client/steam.ts
Normal file
544
packages/core/src/client/steam.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
export namespace Steam {
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/genres.json
|
||||
export const genres = {
|
||||
"1": "Action",
|
||||
"2": "Strategy",
|
||||
"3": "RPG",
|
||||
"4": "Casual",
|
||||
"9": "Racing",
|
||||
"18": "Sports",
|
||||
"23": "Indie",
|
||||
"25": "Adventure",
|
||||
"28": "Simulation",
|
||||
"29": "Massively Multiplayer",
|
||||
"37": "Free to Play",
|
||||
"50": "Accounting",
|
||||
"51": "Animation & Modeling",
|
||||
"52": "Audio Production",
|
||||
"53": "Design & Illustration",
|
||||
"54": "Education",
|
||||
"55": "Photo Editing",
|
||||
"56": "Software Training",
|
||||
"57": "Utilities",
|
||||
"58": "Video Production",
|
||||
"59": "Web Publishing",
|
||||
"60": "Game Development",
|
||||
"70": "Early Access",
|
||||
"71": "Sexual Content",
|
||||
"72": "Nudity",
|
||||
"73": "Violent",
|
||||
"74": "Gore",
|
||||
"80": "Movie",
|
||||
"81": "Documentary",
|
||||
"82": "Episodic",
|
||||
"83": "Short",
|
||||
"84": "Tutorial",
|
||||
"85": "360 Video"
|
||||
}
|
||||
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/categories.json
|
||||
export const categories = {
|
||||
"1": "Multi-player",
|
||||
"2": "Single-player",
|
||||
"6": "Mods (require HL2)",
|
||||
"7": "Mods (require HL1)",
|
||||
"8": "Valve Anti-Cheat enabled",
|
||||
"9": "Co-op",
|
||||
"10": "Demos",
|
||||
"12": "HDR available",
|
||||
"13": "Captions available",
|
||||
"14": "Commentary available",
|
||||
"15": "Stats",
|
||||
"16": "Includes Source SDK",
|
||||
"17": "Includes level editor",
|
||||
"18": "Partial Controller Support",
|
||||
"19": "Mods",
|
||||
"20": "MMO",
|
||||
"21": "Downloadable Content",
|
||||
"22": "Steam Achievements",
|
||||
"23": "Steam Cloud",
|
||||
"24": "Shared/Split Screen",
|
||||
"25": "Steam Leaderboards",
|
||||
"27": "Cross-Platform Multiplayer",
|
||||
"28": "Full controller support",
|
||||
"29": "Steam Trading Cards",
|
||||
"30": "Steam Workshop",
|
||||
"31": "VR Support",
|
||||
"32": "Steam Turn Notifications",
|
||||
"33": "Native Steam Controller",
|
||||
"35": "In-App Purchases",
|
||||
"36": "Online PvP",
|
||||
"37": "Shared/Split Screen PvP",
|
||||
"38": "Online Co-op",
|
||||
"39": "Shared/Split Screen Co-op",
|
||||
"40": "SteamVR Collectibles",
|
||||
"41": "Remote Play on Phone",
|
||||
"42": "Remote Play on Tablet",
|
||||
"43": "Remote Play on TV",
|
||||
"44": "Remote Play Together",
|
||||
"45": "Cloud Gaming",
|
||||
"46": "Cloud Gaming (NVIDIA)",
|
||||
"47": "LAN PvP",
|
||||
"48": "LAN Co-op",
|
||||
"49": "PvP",
|
||||
"50": "Additional High-Quality Audio",
|
||||
"51": "Steam Workshop",
|
||||
"52": "Tracked Controller Support",
|
||||
"53": "VR Supported",
|
||||
"54": "VR Only"
|
||||
}
|
||||
|
||||
// Source: https://files.catbox.moe/96bty7.json
|
||||
export const tags = {
|
||||
"9": "Strategy",
|
||||
"19": "Action",
|
||||
"21": "Adventure",
|
||||
"84": "Design & Illustration",
|
||||
"87": "Utilities",
|
||||
"113": "Free to Play",
|
||||
"122": "RPG",
|
||||
"128": "Massively Multiplayer",
|
||||
"492": "Indie",
|
||||
"493": "Early Access",
|
||||
"597": "Casual",
|
||||
"599": "Simulation",
|
||||
"699": "Racing",
|
||||
"701": "Sports",
|
||||
"784": "Video Production",
|
||||
"809": "Photo Editing",
|
||||
"872": "Animation & Modeling",
|
||||
"1027": "Audio Production",
|
||||
"1036": "Education",
|
||||
"1038": "Web Publishing",
|
||||
"1445": "Software Training",
|
||||
"1616": "Trains",
|
||||
"1621": "Music",
|
||||
"1625": "Platformer",
|
||||
"1628": "Metroidvania",
|
||||
"1638": "Dog",
|
||||
"1643": "Building",
|
||||
"1644": "Driving",
|
||||
"1645": "Tower Defense",
|
||||
"1646": "Hack and Slash",
|
||||
"1647": "Western",
|
||||
"1649": "GameMaker",
|
||||
"1651": "Satire",
|
||||
"1654": "Relaxing",
|
||||
"1659": "Zombies",
|
||||
"1662": "Survival",
|
||||
"1663": "FPS",
|
||||
"1664": "Puzzle",
|
||||
"1665": "Match 3",
|
||||
"1666": "Card Game",
|
||||
"1667": "Horror",
|
||||
"1669": "Moddable",
|
||||
"1670": "4X",
|
||||
"1671": "Superhero",
|
||||
"1673": "Aliens",
|
||||
"1674": "Typing",
|
||||
"1676": "RTS",
|
||||
"1677": "Turn-Based",
|
||||
"1678": "War",
|
||||
"1680": "Heist",
|
||||
"1681": "Pirates",
|
||||
"1684": "Fantasy",
|
||||
"1685": "Co-op",
|
||||
"1687": "Stealth",
|
||||
"1688": "Ninja",
|
||||
"1693": "Classic",
|
||||
"1695": "Open World",
|
||||
"1697": "Third Person",
|
||||
"1698": "Point & Click",
|
||||
"1702": "Crafting",
|
||||
"1708": "Tactical",
|
||||
"1710": "Surreal",
|
||||
"1714": "Psychedelic",
|
||||
"1716": "Roguelike",
|
||||
"1717": "Hex Grid",
|
||||
"1718": "MOBA",
|
||||
"1719": "Comedy",
|
||||
"1720": "Dungeon Crawler",
|
||||
"1721": "Psychological Horror",
|
||||
"1723": "Action RTS",
|
||||
"1730": "Sokoban",
|
||||
"1732": "Voxel",
|
||||
"1733": "Unforgiving",
|
||||
"1734": "Fast-Paced",
|
||||
"1736": "LEGO",
|
||||
"1738": "Hidden Object",
|
||||
"1741": "Turn-Based Strategy",
|
||||
"1742": "Story Rich",
|
||||
"1743": "Fighting",
|
||||
"1746": "Basketball",
|
||||
"1751": "Comic Book",
|
||||
"1752": "Rhythm",
|
||||
"1753": "Skateboarding",
|
||||
"1754": "MMORPG",
|
||||
"1755": "Space",
|
||||
"1756": "Great Soundtrack",
|
||||
"1759": "Perma Death",
|
||||
"1770": "Board Game",
|
||||
"1773": "Arcade",
|
||||
"1774": "Shooter",
|
||||
"1775": "PvP",
|
||||
"1777": "Steampunk",
|
||||
"3796": "Based On A Novel",
|
||||
"3798": "Side Scroller",
|
||||
"3799": "Visual Novel",
|
||||
"3810": "Sandbox",
|
||||
"3813": "Real Time Tactics",
|
||||
"3814": "Third-Person Shooter",
|
||||
"3834": "Exploration",
|
||||
"3835": "Post-apocalyptic",
|
||||
"3839": "First-Person",
|
||||
"3841": "Local Co-Op",
|
||||
"3843": "Online Co-Op",
|
||||
"3854": "Lore-Rich",
|
||||
"3859": "Multiplayer",
|
||||
"3871": "2D",
|
||||
"3877": "Precision Platformer",
|
||||
"3878": "Competitive",
|
||||
"3916": "Old School",
|
||||
"3920": "Cooking",
|
||||
"3934": "Immersive",
|
||||
"3942": "Sci-fi",
|
||||
"3952": "Gothic",
|
||||
"3955": "Character Action Game",
|
||||
"3959": "Roguelite",
|
||||
"3964": "Pixel Graphics",
|
||||
"3965": "Epic",
|
||||
"3968": "Physics",
|
||||
"3978": "Survival Horror",
|
||||
"3987": "Historical",
|
||||
"3993": "Combat",
|
||||
"4004": "Retro",
|
||||
"4018": "Vampire",
|
||||
"4026": "Difficult",
|
||||
"4036": "Parkour",
|
||||
"4046": "Dragons",
|
||||
"4057": "Magic",
|
||||
"4064": "Thriller",
|
||||
"4085": "Anime",
|
||||
"4094": "Minimalist",
|
||||
"4102": "Combat Racing",
|
||||
"4106": "Action-Adventure",
|
||||
"4115": "Cyberpunk",
|
||||
"4136": "Funny",
|
||||
"4137": "Transhumanism",
|
||||
"4145": "Cinematic",
|
||||
"4150": "World War II",
|
||||
"4155": "Class-Based",
|
||||
"4158": "Beat 'em up",
|
||||
"4161": "Real-Time",
|
||||
"4166": "Atmospheric",
|
||||
"4168": "Military",
|
||||
"4172": "Medieval",
|
||||
"4175": "Realistic",
|
||||
"4182": "Singleplayer",
|
||||
"4184": "Chess",
|
||||
"4190": "Addictive",
|
||||
"4191": "3D",
|
||||
"4195": "Cartoony",
|
||||
"4202": "Trading",
|
||||
"4231": "Action RPG",
|
||||
"4234": "Short",
|
||||
"4236": "Loot",
|
||||
"4242": "Episodic",
|
||||
"4252": "Stylized",
|
||||
"4255": "Shoot 'Em Up",
|
||||
"4291": "Spaceships",
|
||||
"4295": "Futuristic",
|
||||
"4305": "Colorful",
|
||||
"4325": "Turn-Based Combat",
|
||||
"4328": "City Builder",
|
||||
"4342": "Dark",
|
||||
"4345": "Gore",
|
||||
"4364": "Grand Strategy",
|
||||
"4376": "Assassin",
|
||||
"4400": "Abstract",
|
||||
"4434": "JRPG",
|
||||
"4474": "CRPG",
|
||||
"4486": "Choose Your Own Adventure",
|
||||
"4508": "Co-op Campaign",
|
||||
"4520": "Farming",
|
||||
"4559": "Quick-Time Events",
|
||||
"4562": "Cartoon",
|
||||
"4598": "Alternate History",
|
||||
"4604": "Dark Fantasy",
|
||||
"4608": "Swordplay",
|
||||
"4637": "Top-Down Shooter",
|
||||
"4667": "Violent",
|
||||
"4684": "Wargame",
|
||||
"4695": "Economy",
|
||||
"4700": "Movie",
|
||||
"4711": "Replay Value",
|
||||
"4726": "Cute",
|
||||
"4736": "2D Fighter",
|
||||
"4747": "Character Customization",
|
||||
"4754": "Politics",
|
||||
"4758": "Twin Stick Shooter",
|
||||
"4777": "Spectacle fighter",
|
||||
"4791": "Top-Down",
|
||||
"4821": "Mechs",
|
||||
"4835": "6DOF",
|
||||
"4840": "4 Player Local",
|
||||
"4845": "Capitalism",
|
||||
"4853": "Political",
|
||||
"4878": "Parody",
|
||||
"4885": "Bullet Hell",
|
||||
"4947": "Romance",
|
||||
"4975": "2.5D",
|
||||
"4994": "Naval Combat",
|
||||
"5030": "Dystopian",
|
||||
"5055": "eSports",
|
||||
"5094": "Narration",
|
||||
"5125": "Procedural Generation",
|
||||
"5153": "Kickstarter",
|
||||
"5154": "Score Attack",
|
||||
"5160": "Dinosaurs",
|
||||
"5179": "Cold War",
|
||||
"5186": "Psychological",
|
||||
"5228": "Blood",
|
||||
"5230": "Sequel",
|
||||
"5300": "God Game",
|
||||
"5310": "Games Workshop",
|
||||
"5348": "Mod",
|
||||
"5350": "Family Friendly",
|
||||
"5363": "Destruction",
|
||||
"5372": "Conspiracy",
|
||||
"5379": "2D Platformer",
|
||||
"5382": "World War I",
|
||||
"5390": "Time Attack",
|
||||
"5395": "3D Platformer",
|
||||
"5407": "Benchmark",
|
||||
"5411": "Beautiful",
|
||||
"5432": "Programming",
|
||||
"5502": "Hacking",
|
||||
"5537": "Puzzle Platformer",
|
||||
"5547": "Arena Shooter",
|
||||
"5577": "RPGMaker",
|
||||
"5608": "Emotional",
|
||||
"5611": "Mature",
|
||||
"5613": "Detective",
|
||||
"5652": "Collectathon",
|
||||
"5673": "Modern",
|
||||
"5708": "Remake",
|
||||
"5711": "Team-Based",
|
||||
"5716": "Mystery",
|
||||
"5727": "Baseball",
|
||||
"5752": "Robots",
|
||||
"5765": "Gun Customization",
|
||||
"5794": "Science",
|
||||
"5796": "Bullet Time",
|
||||
"5851": "Isometric",
|
||||
"5900": "Walking Simulator",
|
||||
"5914": "Tennis",
|
||||
"5923": "Dark Humor",
|
||||
"5941": "Reboot",
|
||||
"5981": "Mining",
|
||||
"5984": "Drama",
|
||||
"6041": "Horses",
|
||||
"6052": "Noir",
|
||||
"6129": "Logic",
|
||||
"6214": "Birds",
|
||||
"6276": "Inventory Management",
|
||||
"6310": "Diplomacy",
|
||||
"6378": "Crime",
|
||||
"6426": "Choices Matter",
|
||||
"6506": "3D Fighter",
|
||||
"6621": "Pinball",
|
||||
"6625": "Time Manipulation",
|
||||
"6650": "Nudity",
|
||||
"6691": "1990's",
|
||||
"6702": "Mars",
|
||||
"6730": "PvE",
|
||||
"6815": "Hand-drawn",
|
||||
"6869": "Nonlinear",
|
||||
"6910": "Naval",
|
||||
"6915": "Martial Arts",
|
||||
"6948": "Rome",
|
||||
"6971": "Multiple Endings",
|
||||
"7038": "Golf",
|
||||
"7107": "Real-Time with Pause",
|
||||
"7108": "Party",
|
||||
"7113": "Crowdfunded",
|
||||
"7178": "Party Game",
|
||||
"7208": "Female Protagonist",
|
||||
"7250": "Linear",
|
||||
"7309": "Skiing",
|
||||
"7328": "Bowling",
|
||||
"7332": "Base Building",
|
||||
"7368": "Local Multiplayer",
|
||||
"7423": "Sniper",
|
||||
"7432": "Lovecraftian",
|
||||
"7478": "Illuminati",
|
||||
"7481": "Controller",
|
||||
"7556": "Dice",
|
||||
"7569": "Grid-Based Movement",
|
||||
"7622": "Offroad",
|
||||
"7702": "Narrative",
|
||||
"7743": "1980s",
|
||||
"7782": "Cult Classic",
|
||||
"7918": "Dwarf",
|
||||
"7926": "Artificial Intelligence",
|
||||
"7948": "Soundtrack",
|
||||
"8013": "Software",
|
||||
"8075": "TrackIR",
|
||||
"8093": "Minigames",
|
||||
"8122": "Level Editor",
|
||||
"8253": "Music-Based Procedural Generation",
|
||||
"8369": "Investigation",
|
||||
"8461": "Well-Written",
|
||||
"8666": "Runner",
|
||||
"8945": "Resource Management",
|
||||
"9130": "Hentai",
|
||||
"9157": "Underwater",
|
||||
"9204": "Immersive Sim",
|
||||
"9271": "Trading Card Game",
|
||||
"9541": "Demons",
|
||||
"9551": "Dating Sim",
|
||||
"9564": "Hunting",
|
||||
"9592": "Dynamic Narration",
|
||||
"9803": "Snow",
|
||||
"9994": "Experience",
|
||||
"10235": "Life Sim",
|
||||
"10383": "Transportation",
|
||||
"10397": "Memes",
|
||||
"10437": "Trivia",
|
||||
"10679": "Time Travel",
|
||||
"10695": "Party-Based RPG",
|
||||
"10808": "Supernatural",
|
||||
"10816": "Split Screen",
|
||||
"11014": "Interactive Fiction",
|
||||
"11095": "Boss Rush",
|
||||
"11104": "Vehicular Combat",
|
||||
"11123": "Mouse only",
|
||||
"11333": "Villain Protagonist",
|
||||
"11634": "Vikings",
|
||||
"12057": "Tutorial",
|
||||
"12095": "Sexual Content",
|
||||
"12190": "Boxing",
|
||||
"12286": "Warhammer 40K",
|
||||
"12472": "Management",
|
||||
"13070": "Solitaire",
|
||||
"13190": "America",
|
||||
"13276": "Tanks",
|
||||
"13382": "Archery",
|
||||
"13577": "Sailing",
|
||||
"13782": "Experimental",
|
||||
"13906": "Game Development",
|
||||
"14139": "Turn-Based Tactics",
|
||||
"14153": "Dungeons & Dragons",
|
||||
"14720": "Nostalgia",
|
||||
"14906": "Intentionally Awkward Controls",
|
||||
"15045": "Flight",
|
||||
"15172": "Conversation",
|
||||
"15277": "Philosophical",
|
||||
"15339": "Documentary",
|
||||
"15564": "Fishing",
|
||||
"15868": "Motocross",
|
||||
"15954": "Silent Protagonist",
|
||||
"16094": "Mythology",
|
||||
"16250": "Gambling",
|
||||
"16598": "Space Sim",
|
||||
"16689": "Time Management",
|
||||
"17015": "Werewolves",
|
||||
"17305": "Strategy RPG",
|
||||
"17337": "Lemmings",
|
||||
"17389": "Tabletop",
|
||||
"17770": "Asynchronous Multiplayer",
|
||||
"17894": "Cats",
|
||||
"17927": "Pool",
|
||||
"18594": "FMV",
|
||||
"19568": "Cycling",
|
||||
"19780": "Submarine",
|
||||
"19995": "Dark Comedy",
|
||||
"21006": "Underground",
|
||||
"21491": "Demo Available",
|
||||
"21725": "Tactical RPG",
|
||||
"21978": "VR",
|
||||
"22602": "Agriculture",
|
||||
"22955": "Mini Golf",
|
||||
"24003": "Word Game",
|
||||
"24904": "NSFW",
|
||||
"25085": "Touch-Friendly",
|
||||
"26921": "Political Sim",
|
||||
"27758": "Voice Control",
|
||||
"28444": "Snowboarding",
|
||||
"29363": "3D Vision",
|
||||
"29482": "Souls-like",
|
||||
"29855": "Ambient",
|
||||
"30358": "Nature",
|
||||
"30927": "Fox",
|
||||
"31275": "Text-Based",
|
||||
"31579": "Otome",
|
||||
"32322": "Deckbuilding",
|
||||
"33572": "Mahjong",
|
||||
"35079": "Job Simulator",
|
||||
"42089": "Jump Scare",
|
||||
"42329": "Coding",
|
||||
"42804": "Action Roguelike",
|
||||
"44868": "LGBTQ+",
|
||||
"47827": "Wrestling",
|
||||
"49213": "Rugby",
|
||||
"51306": "Foreign",
|
||||
"56690": "On-Rails Shooter",
|
||||
"61357": "Electronic Music",
|
||||
"65443": "Adult Content",
|
||||
"71389": "Spelling",
|
||||
"87918": "Farming Sim",
|
||||
"91114": "Shop Keeper",
|
||||
"92092": "Jet",
|
||||
"96359": "Skating",
|
||||
"97376": "Cozy",
|
||||
"102530": "Elf",
|
||||
"117648": "8-bit Music",
|
||||
"123332": "Bikes",
|
||||
"129761": "ATV",
|
||||
"143739": "Electronic",
|
||||
"150626": "Gaming",
|
||||
"158638": "Cricket",
|
||||
"176981": "Battle Royale",
|
||||
"180368": "Faith",
|
||||
"189941": "Instrumental Music",
|
||||
"198631": "Mystery Dungeon",
|
||||
"198913": "Motorbike",
|
||||
"220585": "Colony Sim",
|
||||
"233824": "Feature Film",
|
||||
"252854": "BMX",
|
||||
"255534": "Automation",
|
||||
"323922": "Musou",
|
||||
"324176": "Hockey",
|
||||
"337964": "Rock Music",
|
||||
"348922": "Steam Machine",
|
||||
"353880": "Looter Shooter",
|
||||
"363767": "Snooker",
|
||||
"379975": "Clicker",
|
||||
"454187": "Traditional Roguelike",
|
||||
"552282": "Wholesome",
|
||||
"603297": "Hardware",
|
||||
"615955": "Idler",
|
||||
"620519": "Hero Shooter",
|
||||
"745697": "Social Deduction",
|
||||
"769306": "Escape Room",
|
||||
"776177": "360 Video",
|
||||
"791774": "Card Battler",
|
||||
"847164": "Volleyball",
|
||||
"856791": "Asymmetric VR",
|
||||
"916648": "Creature Collector",
|
||||
"922563": "Roguevania",
|
||||
"1003823": "Profile Features Limited",
|
||||
"1023537": "Boomer Shooter",
|
||||
"1084988": "Auto Battler",
|
||||
"1091588": "Roguelike Deckbuilder",
|
||||
"1100686": "Outbreak Sim",
|
||||
"1100687": "Automobile Sim",
|
||||
"1100688": "Medical Sim",
|
||||
"1100689": "Open World Survival Craft",
|
||||
"1199779": "Extraction Shooter",
|
||||
"1220528": "Hobby Sim",
|
||||
"1254546": "Football (Soccer)",
|
||||
"1254552": "Football (American)",
|
||||
"1368160": "AI Content Disclosed",
|
||||
}
|
||||
}
|
||||
@@ -161,8 +161,8 @@ export interface AppDepots {
|
||||
branches: AppDepotBranches;
|
||||
privatebranches: Record<string, AppDepotBranches>;
|
||||
[depotId: string]: DepotEntry
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
}
|
||||
|
||||
|
||||
@@ -284,16 +284,27 @@ export type GenreType = {
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
slug: string;
|
||||
images: {
|
||||
logo: string;
|
||||
backdrop: string;
|
||||
poster: string;
|
||||
banner: string;
|
||||
screenshots: string[];
|
||||
icon: string;
|
||||
}
|
||||
links: string[] | null;
|
||||
score: number;
|
||||
gameid: string;
|
||||
id: string;
|
||||
releaseDate: Date;
|
||||
description: string;
|
||||
description: string | null;
|
||||
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 }>;
|
||||
categories: Array<{ name: string; slug: string; type: "categorie" }>;
|
||||
franchises: Array<{ name: string; slug: string; type: "franchise" }>;
|
||||
developers: Array<{ name: string; slug: string; type: "developer" }>;
|
||||
publishers: Array<{ name: string; slug: string; type: "publisher" }>;
|
||||
}
|
||||
@@ -342,3 +353,248 @@ export interface RankedShot {
|
||||
url: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface SteamPlayerSummaryResponse {
|
||||
response: {
|
||||
players: SteamPlayerSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamPlayerSummary {
|
||||
steamid: string;
|
||||
communityvisibilitystate: number;
|
||||
profilestate?: number;
|
||||
personaname: string;
|
||||
profileurl: string;
|
||||
avatar: string;
|
||||
avatarmedium: string;
|
||||
avatarfull: string;
|
||||
avatarhash: string;
|
||||
lastlogoff?: number;
|
||||
personastate: number;
|
||||
realname?: string;
|
||||
primaryclanid?: string;
|
||||
timecreated: number;
|
||||
personastateflags?: number;
|
||||
loccountrycode?: string;
|
||||
}
|
||||
|
||||
export interface SteamPlayerBansResponse {
|
||||
players: SteamPlayerBan[];
|
||||
}
|
||||
|
||||
export interface SteamPlayerBan {
|
||||
SteamId: string;
|
||||
CommunityBanned: boolean;
|
||||
VACBanned: boolean;
|
||||
NumberOfVACBans: number;
|
||||
DaysSinceLastBan: number;
|
||||
NumberOfGameBans: number;
|
||||
EconomyBan: 'none' | 'probation' | 'banned'; // Enum based on known possible values
|
||||
}
|
||||
|
||||
export type SteamAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
realName: string | null;
|
||||
steamMemberSince: Date;
|
||||
avatarHash: string;
|
||||
limitations: {
|
||||
isLimited: boolean;
|
||||
tradeBanState: 'none' | 'probation' | 'banned';
|
||||
isVacBanned: boolean;
|
||||
visibilityState: number;
|
||||
privacyState: 'public' | 'private' | 'friendsonly';
|
||||
};
|
||||
profileUrl: string;
|
||||
lastSyncedAt: Date;
|
||||
};
|
||||
|
||||
export interface SteamFriendsListResponse {
|
||||
friendslist: {
|
||||
friends: SteamFriend[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamFriend {
|
||||
steamid: string;
|
||||
relationship: 'friend'; // could expand this if Steam ever adds more types
|
||||
friend_since: number; // Unix timestamp (seconds)
|
||||
}
|
||||
|
||||
export interface SteamOwnedGamesResponse {
|
||||
response: {
|
||||
game_count: number;
|
||||
games: SteamOwnedGame[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamOwnedGame {
|
||||
appid: number;
|
||||
name: string;
|
||||
playtime_forever: number;
|
||||
img_icon_url: string;
|
||||
|
||||
playtime_windows_forever?: number;
|
||||
playtime_mac_forever?: number;
|
||||
playtime_linux_forever?: number;
|
||||
playtime_deck_forever?: number;
|
||||
|
||||
rtime_last_played?: number; // Unix timestamp
|
||||
content_descriptorids?: number[];
|
||||
playtime_disconnected?: number;
|
||||
has_community_visible_stats?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the parsed Steam profile information.
|
||||
*/
|
||||
export interface ProfileInfo {
|
||||
steamID64: string;
|
||||
isLimited: boolean;
|
||||
privacyState: 'public' | 'private' | 'friendsonly' | string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
export interface SteamStoreResponse {
|
||||
response: {
|
||||
store_items: SteamStoreItem[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamStoreItem {
|
||||
item_type: number;
|
||||
id: number;
|
||||
success: number;
|
||||
visible: boolean;
|
||||
name: string;
|
||||
store_url_path: string;
|
||||
appid: number;
|
||||
type: number;
|
||||
tagids: number[];
|
||||
categories: {
|
||||
supported_player_categoryids?: number[];
|
||||
feature_categoryids?: number[];
|
||||
controller_categoryids?: number[];
|
||||
};
|
||||
reviews: {
|
||||
summary_filtered: {
|
||||
review_count: number;
|
||||
percent_positive: number;
|
||||
review_score: number;
|
||||
review_score_label: string;
|
||||
};
|
||||
};
|
||||
basic_info: {
|
||||
short_description?: string;
|
||||
publishers: SteamCreator[];
|
||||
developers: SteamCreator[];
|
||||
franchises?: SteamCreator[];
|
||||
};
|
||||
tags: {
|
||||
tagid: number;
|
||||
weight: number;
|
||||
}[];
|
||||
assets: SteamAssets;
|
||||
assets_without_overrides: SteamAssets;
|
||||
release: {
|
||||
steam_release_date: number;
|
||||
};
|
||||
platforms: {
|
||||
windows: boolean;
|
||||
mac: boolean;
|
||||
steamos_linux: boolean;
|
||||
vr_support: Record<string, never>;
|
||||
steam_deck_compat_category?: number;
|
||||
steam_os_compat_category?: number;
|
||||
};
|
||||
best_purchase_option: PurchaseOption;
|
||||
purchase_options: PurchaseOption[];
|
||||
screenshots: {
|
||||
all_ages_screenshots: {
|
||||
filename: string;
|
||||
ordinal: number;
|
||||
}[];
|
||||
};
|
||||
trailers: {
|
||||
highlights: Trailer[];
|
||||
};
|
||||
supported_languages: SupportedLanguage[];
|
||||
full_description: string;
|
||||
links?: {
|
||||
link_type: number;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface SteamCreator {
|
||||
name: string;
|
||||
creator_clan_account_id: number;
|
||||
}
|
||||
|
||||
export interface SteamAssets {
|
||||
asset_url_format: string;
|
||||
main_capsule: string;
|
||||
small_capsule: string;
|
||||
header: string;
|
||||
page_background: string;
|
||||
hero_capsule: string;
|
||||
hero_capsule_2x: string;
|
||||
library_capsule: string;
|
||||
library_capsule_2x: string;
|
||||
library_hero: string;
|
||||
library_hero_2x: string;
|
||||
community_icon: string;
|
||||
page_background_path: string;
|
||||
raw_page_background: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOption {
|
||||
packageid?: number;
|
||||
bundleid?: number;
|
||||
purchase_option_name: string;
|
||||
final_price_in_cents: string;
|
||||
original_price_in_cents: string;
|
||||
formatted_final_price: string;
|
||||
formatted_original_price: string;
|
||||
discount_pct: number;
|
||||
active_discounts: ActiveDiscount[];
|
||||
user_can_purchase_as_gift: boolean;
|
||||
hide_discount_pct_for_compliance: boolean;
|
||||
included_game_count: number;
|
||||
bundle_discount_pct?: number;
|
||||
price_before_bundle_discount?: string;
|
||||
formatted_price_before_bundle_discount?: string;
|
||||
}
|
||||
|
||||
export interface ActiveDiscount {
|
||||
discount_amount: string;
|
||||
discount_description: string;
|
||||
discount_end_date: number;
|
||||
}
|
||||
|
||||
export interface Trailer {
|
||||
trailer_name: string;
|
||||
trailer_url_format: string;
|
||||
trailer_category: number;
|
||||
trailer_480p: TrailerFile[];
|
||||
trailer_max: TrailerFile[];
|
||||
microtrailer: TrailerFile[];
|
||||
screenshot_medium: string;
|
||||
screenshot_full: string;
|
||||
trailer_base_id: number;
|
||||
all_ages: boolean;
|
||||
}
|
||||
|
||||
export interface TrailerFile {
|
||||
filename: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SupportedLanguage {
|
||||
elanguage: number;
|
||||
eadditionallanguage: number;
|
||||
supported: boolean;
|
||||
full_audio: boolean;
|
||||
subtitles: boolean;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CompareResult,
|
||||
RankedShot,
|
||||
Shot,
|
||||
ProfileInfo,
|
||||
} from "./types";
|
||||
import crypto from 'crypto';
|
||||
import pLimit from 'p-limit';
|
||||
@@ -18,6 +19,7 @@ import { LRUCache } from 'lru-cache';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
import { Agent as HttpsAgent } from 'https';
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import sharp, { type Metadata } from 'sharp';
|
||||
import AbortController from 'abort-controller';
|
||||
import fetch, { RequestInit } from 'node-fetch';
|
||||
@@ -90,29 +92,21 @@ export namespace Utils {
|
||||
|
||||
// --- Optimized Box Art creation ---
|
||||
export async function createBoxArtBuffer(
|
||||
assets: LibraryAssetsFull,
|
||||
appid: number | string,
|
||||
logoUrl: string,
|
||||
backgroundUrl: 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'))
|
||||
fetchBuffer(backgroundUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download hero image for ${appid}:`, error);
|
||||
console.error(`Failed to download hero image from ${backgroundUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: hero image unavailable`);
|
||||
}),
|
||||
),
|
||||
downloadLimit(() => fetchBuffer(pick('library_logo'))
|
||||
downloadLimit(() => fetchBuffer(logoUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download logo image for ${appid}:`, error);
|
||||
console.error(`Failed to download logo image from ${logoUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: logo image unavailable`);
|
||||
}),
|
||||
),
|
||||
@@ -182,9 +176,11 @@ export namespace Utils {
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s -]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.normalize("NFKD") // Normalize to decompose accented characters
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '') // Keep Unicode letters, numbers, spaces, and hyphens
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -328,16 +324,26 @@ export namespace Utils {
|
||||
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
|
||||
switch (type) {
|
||||
case "1":
|
||||
return "low";
|
||||
return "high";
|
||||
case "2":
|
||||
return "mid";
|
||||
case "3":
|
||||
return "high";
|
||||
return "low";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function estimateRatingFromSummary(
|
||||
reviewCount: number,
|
||||
percentPositive: number
|
||||
): number {
|
||||
const positiveVotes = Math.round((percentPositive / 100) * reviewCount);
|
||||
const negativeVotes = reviewCount - positiveVotes;
|
||||
return getRating(positiveVotes, negativeVotes);
|
||||
}
|
||||
|
||||
export function mapGameTags<
|
||||
T extends string = "tag"
|
||||
>(
|
||||
@@ -353,6 +359,20 @@ export namespace Utils {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createType<
|
||||
T extends "developer" | "publisher" | "franchise" | "tag" | "categorie" | "genre"
|
||||
>(
|
||||
names: string[],
|
||||
type: T
|
||||
) {
|
||||
return names
|
||||
.map(name => ({
|
||||
type,
|
||||
name: name.trim(),
|
||||
slug: createSlug(name.trim())
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tag object with name, slug, and type
|
||||
* @typeparam T Literal type of the `type` field (defaults to 'tag')
|
||||
@@ -380,17 +400,39 @@ export namespace Utils {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isDepotEntry(e: any): e is DepotEntry {
|
||||
return (
|
||||
e != null &&
|
||||
typeof e === 'object' &&
|
||||
'manifests' in e &&
|
||||
e.manifests != null &&
|
||||
typeof e.manifests.public?.download === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublicDepotSizes(depots: AppDepots) {
|
||||
const sum = { download: 0, size: 0 };
|
||||
for (const key in depots) {
|
||||
let download = 0;
|
||||
let size = 0;
|
||||
|
||||
for (const key of Object.keys(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);
|
||||
if (!isDepotEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dl = Number(entry.manifests.public.download);
|
||||
const sz = Number(entry.manifests.public.size);
|
||||
if (!Number.isFinite(dl) || !Number.isFinite(sz)) {
|
||||
console.warn(`[getPublicDepotSizes] non-numeric size for depot ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
download += dl;
|
||||
size += sz;
|
||||
}
|
||||
return { downloadSize: sum.download, sizeOnDisk: sum.size };
|
||||
|
||||
return { downloadSize: download, sizeOnDisk: size };
|
||||
}
|
||||
|
||||
export function parseGenres(str: string): GenreType[] {
|
||||
@@ -419,4 +461,64 @@ export namespace Utils {
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and parses a single Steam community profile XML.
|
||||
* @param steamIdOrVanity - The 64-bit SteamID or vanity name.
|
||||
* @returns Promise resolving to ProfileInfo.
|
||||
*/
|
||||
export async function fetchProfileInfo(
|
||||
steamIdOrVanity: string
|
||||
): Promise<ProfileInfo> {
|
||||
const isNumericId = /^\d+$/.test(steamIdOrVanity);
|
||||
const path = isNumericId ? `profiles/${steamIdOrVanity}` : `id/${steamIdOrVanity}`;
|
||||
const url = `https://steamcommunity.com/${path}/?xml=1`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${steamIdOrVanity}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const { profile } = await parseStringPromise(xml, {
|
||||
explicitArray: false,
|
||||
trim: true,
|
||||
mergeAttrs: true
|
||||
}) as { profile: any };
|
||||
|
||||
// Extract fields (fall back to limitedAccount tag if needed)
|
||||
const limitedFlag = profile.isLimitedAccount ?? profile.limitedAccount;
|
||||
const isLimited = limitedFlag === '1';
|
||||
|
||||
return {
|
||||
isLimited,
|
||||
steamID64: profile.steamID64,
|
||||
privacyState: profile.privacyState,
|
||||
visibility: profile.visibilityState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetches multiple Steam profiles in parallel.
|
||||
* @param idsOrVanities - Array of SteamID64 strings or vanity names.
|
||||
* @returns Promise resolving to a record mapping each input to its ProfileInfo or an error.
|
||||
*/
|
||||
export async function fetchProfilesInfo(
|
||||
idsOrVanities: string[]
|
||||
): Promise<Map<string, ProfileInfo | { error: string }>> {
|
||||
const results = await Promise.all(
|
||||
idsOrVanities.map(async (input) => {
|
||||
try {
|
||||
const info = await fetchProfileInfo(input);
|
||||
return { input, result: info };
|
||||
} catch (err) {
|
||||
return { input, result: { error: (err as Error).message } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return new Map(
|
||||
results.map(({ input, result }) => [input, result] as [string, ProfileInfo | { error: string }])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import "zod-openapi/extend";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export namespace Common {
|
||||
export const IdDescription = `Unique object identifier.
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
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: ulid("id").notNull(),
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
refreshToken: encryptedText("refresh_token")
|
||||
.notNull(),
|
||||
expiry: utc("expiry").notNull(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.id]
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -1,93 +0,0 @@
|
||||
import { z } from "zod";
|
||||
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";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Credentials {
|
||||
export const Info = createSelectSchema(steamCredentialsTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
.extend({
|
||||
accessToken: z.string(),
|
||||
cookies: z.string().array()
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_credentials.added",
|
||||
z.object({
|
||||
steamID: Info.shape.steamID,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.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 ?? 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.steamID })
|
||||
);
|
||||
return id
|
||||
})
|
||||
});
|
||||
|
||||
export const fromSteamID = 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,
|
||||
) {
|
||||
return {
|
||||
id: input.id,
|
||||
expiry: input.expiry,
|
||||
steamID: input.steamID,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Token } from "../utils";
|
||||
import { char, customType, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
@@ -33,19 +32,6 @@ export const utc = (name: string) =>
|
||||
// mode: "date"
|
||||
});
|
||||
|
||||
export const encryptedText =
|
||||
customType<{ data: string; driverData: string; }>({
|
||||
dataType() {
|
||||
return 'text';
|
||||
},
|
||||
fromDriver(val) {
|
||||
return Token.decrypt(val);
|
||||
},
|
||||
toDriver(val) {
|
||||
return Token.encrypt(val);
|
||||
},
|
||||
});
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc("time_created").notNull().defaultNow(),
|
||||
timeUpdated: utc("time_updated").notNull().defaultNow(),
|
||||
|
||||
@@ -34,7 +34,7 @@ export namespace Examples {
|
||||
|
||||
export const SteamAccount = {
|
||||
status: "online" as const, //offline,dnd(do not disturb) or playing
|
||||
id: "74839300282033",// Primary key
|
||||
id: "74839300282033",// Steam ID
|
||||
userID: User.id,// | null FK to User (null if not linked)
|
||||
name: "JD The 65th",
|
||||
username: "jdoe",
|
||||
@@ -55,11 +55,10 @@ export namespace Examples {
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),// Primary key
|
||||
name: "John's Console", // Team name (not null, unique)
|
||||
ownerID: User.id, // FK to User who owns/created the team
|
||||
slug: SteamAccount.profileUrl.toLowerCase(),
|
||||
name: "John", // Team name (not null, unique)
|
||||
maxMembers: 3,
|
||||
inviteCode: "xwydjf",
|
||||
ownerSteamID: SteamAccount.id, // FK to User who owns/created the team
|
||||
members: [SteamAccount]
|
||||
};
|
||||
|
||||
@@ -152,6 +151,9 @@ export namespace Examples {
|
||||
id: "1809540",
|
||||
slug: "nine-sols",
|
||||
name: "Nine Sols",
|
||||
links:[
|
||||
"https://example.com"
|
||||
],
|
||||
controllerSupport: "full" as const,
|
||||
releaseDate: new Date("2024-05-29T06:53:24.000Z"),
|
||||
compatibility: "high" as const,
|
||||
@@ -205,6 +207,13 @@ export namespace Examples {
|
||||
slug: "redcandlegames"
|
||||
}
|
||||
],
|
||||
franchises: [],
|
||||
categories: [
|
||||
{
|
||||
name: "Partial Controller",
|
||||
slug: "partial-controller"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const CommonImg = [
|
||||
|
||||
@@ -8,9 +8,9 @@ import { friendTable } from "./friend.sql";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Friend {
|
||||
|
||||
@@ -19,24 +19,21 @@ export namespace Library {
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Queue: createEvent(
|
||||
"library.queue",
|
||||
Add: createEvent(
|
||||
"library.add",
|
||||
z.object({
|
||||
appID: z.number(),
|
||||
lastPlayed: z.date(),
|
||||
timeAcquired: z.date(),
|
||||
lastPlayed: z.date().nullable(),
|
||||
totalPlaytime: z.number(),
|
||||
isFamilyShared: z.boolean(),
|
||||
isFamilyShareable: z.boolean(),
|
||||
}).array(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const add = fn(
|
||||
Info.partial({ ownerID: true }),
|
||||
Info.partial({ ownerSteamID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const ownerSteamID = input.ownerID ?? Actor.steamID()
|
||||
const ownerSteamID = input.ownerSteamID ?? Actor.steamID()
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
@@ -44,7 +41,7 @@ export namespace Library {
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
eq(steamLibraryTable.ownerID, ownerSteamID),
|
||||
eq(steamLibraryTable.ownerSteamID, ownerSteamID),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
@@ -57,21 +54,17 @@ export namespace Library {
|
||||
await tx
|
||||
.insert(steamLibraryTable)
|
||||
.values({
|
||||
ownerID: ownerSteamID,
|
||||
ownerSteamID: ownerSteamID,
|
||||
baseGameID: input.baseGameID,
|
||||
lastPlayed: input.lastPlayed,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
timeAcquired: input.timeAcquired,
|
||||
isFamilyShared: input.isFamilyShared
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [steamLibraryTable.ownerID, steamLibraryTable.baseGameID],
|
||||
target: [steamLibraryTable.ownerSteamID, steamLibraryTable.baseGameID],
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
lastPlayed: input.lastPlayed,
|
||||
timeAcquired: input.timeAcquired,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
isFamilyShared: input.isFamilyShared
|
||||
}
|
||||
})
|
||||
|
||||
@@ -87,7 +80,7 @@ export namespace Library {
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, input.ownerID),
|
||||
eq(steamLibraryTable.ownerSteamID, input.ownerSteamID),
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
)
|
||||
)
|
||||
@@ -105,7 +98,7 @@ export namespace Library {
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, Actor.steamID()),
|
||||
eq(steamLibraryTable.ownerSteamID, Actor.steamID()),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { timestamps, utc, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { timestamps, utc, } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { boolean, index, integer, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
import { index, integer, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
|
||||
export const steamLibraryTable = pgTable(
|
||||
"game_libraries",
|
||||
@@ -12,20 +12,18 @@ export const steamLibraryTable = pgTable(
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
ownerID: varchar("owner_id", { length: 255 })
|
||||
ownerSteamID: varchar("owner_steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
timeAcquired: utc("time_acquired").notNull(),
|
||||
lastPlayed: utc("last_played").notNull(),
|
||||
lastPlayed: utc("last_played"),
|
||||
totalPlaytime: integer("total_playtime").notNull(),
|
||||
isFamilyShared: boolean("is_family_shared").notNull()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.ownerID]
|
||||
columns: [table.baseGameID, table.ownerSteamID]
|
||||
}),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerID),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerSteamID),
|
||||
],
|
||||
);
|
||||
@@ -1,122 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Actor } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createID, fn } from "../utils";
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { memberTable, RoleEnum } from "./member.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Member {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Member.id,
|
||||
}),
|
||||
teamID: z.string().openapi({
|
||||
description: "Associated team identifier for this membership",
|
||||
example: Examples.Member.teamID
|
||||
}),
|
||||
role: z.enum(RoleEnum.enumValues).openapi({
|
||||
description: "Assigned permission role within the team",
|
||||
example: Examples.Member.role
|
||||
}),
|
||||
steamID: z.string().openapi({
|
||||
description: "Steam platform identifier for Steam account integration",
|
||||
example: Examples.Member.steamID
|
||||
}),
|
||||
userID: z.string().nullable().openapi({
|
||||
description: "Optional associated user account identifier",
|
||||
example: Examples.Member.userID
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Member",
|
||||
description: "Team membership entity defining user roles and platform connections",
|
||||
example: Examples.Member,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.partial({
|
||||
id: true,
|
||||
userID: true,
|
||||
teamID: true
|
||||
}),
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("member");
|
||||
await tx.insert(memberTable).values({
|
||||
id,
|
||||
role: input.role,
|
||||
userID: input.userID,
|
||||
steamID: input.steamID,
|
||||
teamID: input.teamID ?? Actor.teamID(),
|
||||
})
|
||||
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
export const fromTeamID = fn(
|
||||
Info.shape.teamID,
|
||||
(teamID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, Actor.userID()),
|
||||
eq(memberTable.teamID, teamID),
|
||||
isNull(memberTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
export const fromUserID = fn(
|
||||
z.string(),
|
||||
(userID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.userID, userID),
|
||||
eq(memberTable.teamID, Actor.teamID()),
|
||||
isNull(memberTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw member database row into a standardized {@link Member.Info} object.
|
||||
*
|
||||
* @param input - The database row representing a member.
|
||||
* @returns The member information formatted as a {@link Member.Info} object.
|
||||
*/
|
||||
export function serialize(
|
||||
input: typeof memberTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
role: input.role,
|
||||
userID: input.userID,
|
||||
teamID: input.teamID,
|
||||
steamID: input.steamID
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { isNotNull } from "drizzle-orm";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { timestamps, teamID, ulid } from "../drizzle/types";
|
||||
import { bigint, pgEnum, pgTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const RoleEnum = pgEnum("member_role", ["child", "adult"])
|
||||
|
||||
export const memberTable = pgTable(
|
||||
"members",
|
||||
{
|
||||
...teamID,
|
||||
...timestamps,
|
||||
userID: ulid("user_id")
|
||||
.references(() => userTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "restrict"
|
||||
}),
|
||||
role: RoleEnum("role").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.id, table.teamID] }),
|
||||
uniqueIndex("idx_member_steam_id").on(table.teamID, table.steamID),
|
||||
uniqueIndex("idx_member_user_id")
|
||||
.on(table.teamID, table.userID)
|
||||
.where(isNotNull(table.userID))
|
||||
],
|
||||
);
|
||||
@@ -1,15 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Actor } from "../actor";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { createEvent } from "../event";
|
||||
import { Examples } from "../examples";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||
import { steamTable, StatusEnum, Limitations } from "./steam.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Steam {
|
||||
export const Info = z
|
||||
@@ -34,14 +31,6 @@ export namespace Steam {
|
||||
description: "The steam community url of this account",
|
||||
example: Examples.SteamAccount.profileUrl
|
||||
}),
|
||||
username: z.string()
|
||||
.regex(/^[a-z0-9]{1,32}$/, "The Steam username is not slug friendly")
|
||||
.nullable()
|
||||
.openapi({
|
||||
description: "The unique username of this account",
|
||||
example: Examples.SteamAccount.username
|
||||
})
|
||||
.default("unknown"),
|
||||
realName: z.string().nullable().openapi({
|
||||
description: "The real name behind of this Steam account",
|
||||
example: Examples.SteamAccount.realName
|
||||
@@ -76,7 +65,7 @@ export namespace Steam {
|
||||
"steam_account.created",
|
||||
z.object({
|
||||
steamID: Info.shape.id,
|
||||
userID: Info.shape.userID
|
||||
userID: Info.shape.userID,
|
||||
}),
|
||||
),
|
||||
Updated: createEvent(
|
||||
@@ -94,9 +83,9 @@ export namespace Steam {
|
||||
useUser: z.boolean(),
|
||||
})
|
||||
.partial({
|
||||
useUser: true,
|
||||
userID: true,
|
||||
status: true,
|
||||
useUser: true,
|
||||
lastSyncedAt: true
|
||||
}),
|
||||
(input) =>
|
||||
@@ -107,8 +96,8 @@ export namespace Steam {
|
||||
.from(steamTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamTable.id, input.id),
|
||||
isNull(steamTable.timeDeleted)
|
||||
isNull(steamTable.timeDeleted),
|
||||
eq(steamTable.id, input.id)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
@@ -129,7 +118,6 @@ export namespace Steam {
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
status: input.status ?? "offline",
|
||||
username: input.username ?? "unknown",
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
lastSyncedAt: input.lastSyncedAt ?? Common.utc(),
|
||||
})
|
||||
@@ -151,8 +139,8 @@ export namespace Steam {
|
||||
.partial({
|
||||
userID: true
|
||||
}),
|
||||
(input) =>
|
||||
useTransaction(async (tx) => {
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const userID = input.userID ?? Actor.userID()
|
||||
await tx
|
||||
.update(steamTable)
|
||||
@@ -160,6 +148,12 @@ export namespace Steam {
|
||||
userID
|
||||
})
|
||||
.where(eq(steamTable.id, input.steamID));
|
||||
|
||||
// await afterTx(async () =>
|
||||
// bus.publish(Resource.Bus, Events.Updated, { userID, steamID: input.steamID })
|
||||
// );
|
||||
|
||||
return input.steamID
|
||||
})
|
||||
)
|
||||
|
||||
@@ -177,6 +171,26 @@ export namespace Steam {
|
||||
)
|
||||
)
|
||||
|
||||
export const confirmOwnerShip = fn(
|
||||
z.string().min(1),
|
||||
(userID) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(steamTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamTable.userID, userID),
|
||||
eq(steamTable.id, Actor.steamID()),
|
||||
isNull(steamTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(steamTable.timeCreated))
|
||||
.execute()
|
||||
.then((rows) => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export const fromSteamID = fn(
|
||||
z.string(),
|
||||
(steamID) =>
|
||||
@@ -208,15 +222,14 @@ export namespace Steam {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
userID: input.userID,
|
||||
status: input.status,
|
||||
username: input.username,
|
||||
userID: input.userID,
|
||||
realName: input.realName,
|
||||
profileUrl: input.profileUrl,
|
||||
avatarHash: input.avatarHash,
|
||||
limitations: input.limitations,
|
||||
lastSyncedAt: input.lastSyncedAt,
|
||||
steamMemberSince: input.steamMemberSince,
|
||||
profileUrl: input.profileUrl ? `https://steamcommunity.com/id/${input.profileUrl}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { timestamps, ulid, utc } from "../drizzle/types";
|
||||
import { id, timestamps, ulid, utc } from "../drizzle/types";
|
||||
import { pgTable, varchar, pgEnum, json, unique } from "drizzle-orm/pg-core";
|
||||
|
||||
export const StatusEnum = pgEnum("steam_status", ["online", "offline", "dnd", "playing"])
|
||||
@@ -32,11 +32,7 @@ export const steamTable = pgTable(
|
||||
steamMemberSince: utc("member_since").notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
profileUrl: varchar("profile_url", { length: 255 }),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
avatarHash: varchar("avatar_hash", { length: 255 }).notNull(),
|
||||
limitations: json("limitations").$type<Limitations>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
unique("idx_steam_username").on(table.username)
|
||||
]
|
||||
}
|
||||
);
|
||||
@@ -1,188 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Common } from "../common";
|
||||
import { teamTable } from "./team.sql";
|
||||
import { Examples } from "../examples";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
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";
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.Team.id,
|
||||
}),
|
||||
slug: z.string().regex(/^[a-z0-9-]{1,32}$/, "Use a URL friendly name.").openapi({
|
||||
description: "URL-friendly unique username (lowercase alphanumeric with hyphens)",
|
||||
example: Examples.Team.slug
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "Display name of the team",
|
||||
example: Examples.Team.name
|
||||
}),
|
||||
ownerID: z.string().openapi({
|
||||
description: "Unique identifier of the team owner",
|
||||
example: Examples.Team.ownerID
|
||||
}),
|
||||
maxMembers: z.number().openapi({
|
||||
description: "Maximum allowed team members based on subscription tier",
|
||||
example: Examples.Team.maxMembers
|
||||
}),
|
||||
inviteCode: z.string().openapi({
|
||||
description: "Unique invitation code used for adding new team members",
|
||||
example: Examples.Team.inviteCode
|
||||
}),
|
||||
members: Steam.Info.array().openapi({
|
||||
description: "All the team members in this team",
|
||||
example: Examples.Team.members
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Team",
|
||||
description: "Team entity containing core team information and settings",
|
||||
example: Examples.Team,
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
/**
|
||||
* Generates a unique team invite code
|
||||
* @param length The length of the invite code
|
||||
* @param maxAttempts Maximum number of attempts to generate a unique code
|
||||
* @returns A promise resolving to a unique invite code
|
||||
*/
|
||||
async function createUniqueTeamInviteCode(
|
||||
tx: Transaction,
|
||||
length: number = 8,
|
||||
maxAttempts: number = 5
|
||||
): Promise<string> {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const code = Invite.generateCode(length);
|
||||
|
||||
const teams =
|
||||
await tx
|
||||
.select()
|
||||
.from(teamTable)
|
||||
.where(eq(teamTable.inviteCode, code))
|
||||
.execute()
|
||||
|
||||
if (teams.length === 0) {
|
||||
return code;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// If we've exceeded max attempts, add timestamp to ensure uniqueness
|
||||
const timestampSuffix = Date.now().toString(36).slice(-4);
|
||||
const baseCode = Invite.generateCode(length - 4);
|
||||
return baseCode + timestampSuffix;
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({ members: true })
|
||||
.partial({
|
||||
id: true,
|
||||
inviteCode: true,
|
||||
maxMembers: true,
|
||||
ownerID: true
|
||||
}),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const inviteCode = await createUniqueTeamInviteCode(tx)
|
||||
const id = input.id ?? createID("team");
|
||||
await tx
|
||||
.insert(teamTable)
|
||||
.values({
|
||||
id,
|
||||
inviteCode,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
ownerID: input.ownerID ?? Actor.userID(),
|
||||
maxMembers: input.maxMembers ?? 1,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [teamTable.slug],
|
||||
set: {
|
||||
timeDeleted: null
|
||||
}
|
||||
})
|
||||
|
||||
return id;
|
||||
})
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
steam_accounts: steamTable,
|
||||
teams: teamTable
|
||||
})
|
||||
.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),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.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>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.teams.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
id: group[0].teams.id,
|
||||
slug: group[0].teams.slug,
|
||||
name: group[0].teams.name,
|
||||
ownerID: group[0].teams.ownerID,
|
||||
maxMembers: group[0].teams.maxMembers,
|
||||
inviteCode: group[0].teams.inviteCode,
|
||||
members: group.map(i => i.steam_accounts)
|
||||
.filter((c): c is typeof steamTable.$inferSelect => Boolean(c))
|
||||
.map((item) => Steam.serialize(item))
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { timestamps, id, ulid } from "../drizzle/types";
|
||||
import {
|
||||
varchar,
|
||||
pgTable,
|
||||
bigint,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
|
||||
export const teamTable = pgTable(
|
||||
"teams",
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
ownerID: ulid("owner_id")
|
||||
.notNull()
|
||||
.references(() => userTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
inviteCode: varchar("invite_code", { length: 10 }).notNull(),
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.username, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
maxMembers: bigint("max_members", { mode: "number" }).notNull(),
|
||||
},
|
||||
(team) => [
|
||||
uniqueIndex("idx_team_slug").on(team.slug),
|
||||
unique("idx_team_invite_code").on(team.inviteCode)
|
||||
]
|
||||
);
|
||||
@@ -1,15 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { createEvent } from "../event";
|
||||
import { Polar } from "../polar/index";
|
||||
import { createID, fn } from "../utils";
|
||||
import { userTable } from "./user.sql";
|
||||
import { Examples } from "../examples";
|
||||
import { and, eq, isNull, asc} from "drizzle-orm";
|
||||
import { and, eq, isNull, asc } from "drizzle-orm";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace User {
|
||||
export const Info = z
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from "./id"
|
||||
export * from "./fn"
|
||||
export * from "./log"
|
||||
export * from "./id"
|
||||
export * from "./invite"
|
||||
export * from "./token"
|
||||
export * from "./helper"
|
||||
@@ -1,58 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { fn } from './fn';
|
||||
import crypto from 'crypto';
|
||||
import { Resource } from 'sst';
|
||||
|
||||
// This is a 32-character random ASCII string
|
||||
const rawKey = Resource.SteamEncryptionKey.value;
|
||||
|
||||
// Turn it into exactly 32 bytes via UTF-8
|
||||
const key = Buffer.from(rawKey, 'utf8');
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
`SteamEncryptionKey must be exactly 32 bytes; got ${key.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const ENCRYPTION_IV_LENGTH = 12; // 96 bits for GCM
|
||||
|
||||
export namespace Token {
|
||||
export const encrypt = fn(
|
||||
z.string().min(4),
|
||||
(token) => {
|
||||
const iv = crypto.randomBytes(ENCRYPTION_IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(token, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return ['v1', iv.toString('hex'), tag.toString('hex'), ciphertext.toString('hex')].join(':');
|
||||
});
|
||||
|
||||
export const decrypt = fn(
|
||||
z.string().min(4),
|
||||
(data) => {
|
||||
const [version, ivHex, tagHex, ciphertextHex] = data.split(':');
|
||||
if (version !== 'v1' || !ivHex || !tagHex || !ciphertextHex) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
const ciphertext = Buffer.from(ciphertextHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return plaintext.toString('utf8');
|
||||
});
|
||||
|
||||
}
|
||||
@@ -14,17 +14,19 @@
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actor-core/bun": "^0.8.0",
|
||||
"@actor-core/file-system": "^0.8.0",
|
||||
"@aws-sdk/client-lambda": "^3.821.0",
|
||||
"@aws-sdk/client-s3": "^3.806.0",
|
||||
"@aws-sdk/client-sqs": "^3.806.0",
|
||||
"@nestri/core": "workspace:",
|
||||
"actor-core": "^0.8.0",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"hono": "^4.7.8",
|
||||
"hono-openapi": "^0.4.8",
|
||||
"steam-session": "*",
|
||||
"steamcommunity": "^3.48.6",
|
||||
"steamid": "^2.1.0"
|
||||
"hono-openapi": "^0.4.8"
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export namespace AccountApi {
|
||||
schema: Result(
|
||||
Account.Info.openapi({
|
||||
description: "User account information",
|
||||
example: { ...Examples.User, teams: [Examples.Team] }
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] }
|
||||
})
|
||||
),
|
||||
},
|
||||
|
||||
137
packages/functions/src/api/image.ts
Normal file
137
packages/functions/src/api/image.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import Sharp from "sharp";
|
||||
import { Resource } from "sst";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
const s3 = new S3Client();
|
||||
|
||||
interface TimingMetrics {
|
||||
download: number;
|
||||
transform: number;
|
||||
upload?: number;
|
||||
}
|
||||
|
||||
const formatTimingHeader = (metrics: TimingMetrics): string => {
|
||||
const timings = [
|
||||
`img-download;dur=${Math.round(metrics.download)}`,
|
||||
`img-transform;dur=${Math.round(metrics.transform)}`,
|
||||
];
|
||||
|
||||
if (metrics.upload !== undefined) {
|
||||
timings.push(`img-upload;dur=${Math.round(metrics.upload)}`);
|
||||
}
|
||||
|
||||
return timings.join(",");
|
||||
};
|
||||
|
||||
|
||||
export namespace ImageApi {
|
||||
export const route = new Hono()
|
||||
.post("/:hash",
|
||||
validator("json",
|
||||
z.object({
|
||||
dpr: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
quality: z.number().optional(),
|
||||
format: z.enum(["avif", "webp", "jpeg"]),
|
||||
})
|
||||
),
|
||||
// validator("header",
|
||||
// z.object({
|
||||
// secretKey: z.string(),
|
||||
// })
|
||||
// ),
|
||||
validator("param",
|
||||
z.object({
|
||||
hash: z.string(),
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const input = c.req.valid("json");
|
||||
const { hash } = c.req.valid("param");
|
||||
// const secret = c.req.valid("header").secretKey
|
||||
|
||||
const metrics: TimingMetrics = {
|
||||
download: 0,
|
||||
transform: 0,
|
||||
};
|
||||
|
||||
const downloadStart = performance.now();
|
||||
let originalImage: Buffer;
|
||||
let contentType: string;
|
||||
try {
|
||||
const getCommand = new GetObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: hash,
|
||||
});
|
||||
const response = await s3.send(getCommand);
|
||||
|
||||
originalImage = Buffer.from(await response.Body!.transformToByteArray());
|
||||
contentType = response.ContentType || "image/jpeg";
|
||||
metrics.download = performance.now() - downloadStart;
|
||||
} catch (error) {
|
||||
throw new HTTPException(500, { message: `Error downloading original image:${error}` });
|
||||
}
|
||||
|
||||
|
||||
const transformStart = performance.now();
|
||||
let transformedImage: Buffer;
|
||||
|
||||
try {
|
||||
let sharpInstance = Sharp(originalImage, {
|
||||
failOn: "none",
|
||||
animated: true,
|
||||
});
|
||||
|
||||
const metadata = await sharpInstance.metadata();
|
||||
|
||||
// Apply transformations
|
||||
if (input.width || input.height) {
|
||||
sharpInstance = sharpInstance.resize({
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
});
|
||||
}
|
||||
|
||||
if (metadata.orientation) {
|
||||
sharpInstance = sharpInstance.rotate();
|
||||
}
|
||||
|
||||
if (input.format) {
|
||||
const isLossy = ["jpeg", "webp", "avif"].includes(input.format);
|
||||
|
||||
if (isLossy && input.quality) {
|
||||
sharpInstance = sharpInstance.toFormat(input.format, {
|
||||
quality: input.quality,
|
||||
});
|
||||
} else {
|
||||
sharpInstance = sharpInstance.toFormat(input.format);
|
||||
}
|
||||
}
|
||||
|
||||
transformedImage = await sharpInstance.toBuffer();
|
||||
metrics.transform = performance.now() - transformStart;
|
||||
|
||||
contentType = `image/${input.format}`;
|
||||
} catch (error) {
|
||||
throw new HTTPException(500, { message: `Error transforming image:${error}` });
|
||||
}
|
||||
|
||||
return c.newResponse(transformedImage,
|
||||
200,
|
||||
{
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "max-age=31536000",
|
||||
"Server-Timing": formatTimingHeader(metrics),
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ app
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/games",GameApi.route)
|
||||
.route("/games", GameApi.route)
|
||||
.route("/steam", SteamApi.route)
|
||||
.route("/realtime", Realtime.route)
|
||||
.route("/friends", FriendApi.route)
|
||||
@@ -77,15 +77,15 @@ app.get(
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
TeamID: {
|
||||
SteamID: {
|
||||
type: "apiKey",
|
||||
description: "The team ID to use for this query",
|
||||
description: "The steam ID to use for this query",
|
||||
in: "header",
|
||||
name: "x-nestri-team"
|
||||
name: "x-nestri-steam"
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ Bearer: [], TeamID: [] }],
|
||||
security: [{ Bearer: [], SteamID: [] }],
|
||||
servers: [
|
||||
{ description: "Production", url: "https://api.nestri.io" },
|
||||
{ description: "Sandbox", url: "https://api.dev.nestri.io" },
|
||||
@@ -98,7 +98,7 @@ export default {
|
||||
port: 3001,
|
||||
idleTimeout: 255,
|
||||
webSocketHandler: Realtime.webSocketHandler,
|
||||
fetch: (req: Request,env: Env) =>
|
||||
fetch: (req: Request, env: Env) =>
|
||||
app.fetch(req, env, {
|
||||
waitUntil: (fn) => fn,
|
||||
passThroughOnException: () => { },
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import crypto from 'crypto';
|
||||
import { Resource } from "sst";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { Friend } from "@nestri/core/friend/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 { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { ErrorResponses, validator, Result, notPublic } from "./utils";
|
||||
|
||||
const sqs = new SQSClient({});
|
||||
|
||||
export namespace SteamApi {
|
||||
export const route = new Hono()
|
||||
@@ -45,273 +41,214 @@ export namespace SteamApi {
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
notPublic,
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Steam.list()
|
||||
})
|
||||
)
|
||||
.get("/login",
|
||||
.get("/callback/:id",
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the user to login",
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const cookieID = getCookie(c, "user_id");
|
||||
|
||||
const userID = c.req.valid("param").id;
|
||||
|
||||
if (!cookieID || cookieID !== userID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"You should not be here"
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser = await User.fromID(userID);
|
||||
if (!currentUser) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
const params = new URL(c.req.url).searchParams;
|
||||
|
||||
// Verify OpenID response and get steamID
|
||||
const steamID = await Client.verifyOpenIDResponse(params);
|
||||
|
||||
// If verification failed, return error
|
||||
if (!steamID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"Invalid OpenID authentication response"
|
||||
);
|
||||
}
|
||||
|
||||
const user = (await Client.getUserInfo([steamID]))[0];
|
||||
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
"internal",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"Steam user data is missing"
|
||||
);
|
||||
}
|
||||
|
||||
const wasAdded = await Steam.create({ ...user, userID });
|
||||
|
||||
if (!wasAdded) {
|
||||
// Update the owner of the Steam account
|
||||
await Steam.updateOwner({ userID, steamID })
|
||||
}
|
||||
|
||||
c.executionCtx.waitUntil((async () => {
|
||||
try {
|
||||
// Get friends info
|
||||
const friends = await Client.getFriendsList(steamID);
|
||||
|
||||
const friendSteamIDs = friends.friendslist.friends.map(f => f.steamid);
|
||||
|
||||
// Steam API has a limit of requesting 100 friends at a go
|
||||
const friendChunks = chunkArray(friendSteamIDs, 100);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
friendChunks.map(async (friendIDs) => {
|
||||
const friendsInfo = await Client.getUserInfo(friendIDs)
|
||||
|
||||
return await Promise.all(
|
||||
friendsInfo.map(async (friend) => {
|
||||
const wasAdded = await Steam.create(friend);
|
||||
|
||||
if (!wasAdded) {
|
||||
console.log(`Friend ${friend.id} already exists`)
|
||||
}
|
||||
|
||||
await Friend.add({ friendSteamID: friend.id, steamID })
|
||||
|
||||
return friend.id
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
settled
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
|
||||
|
||||
const prod = (Resource.App.stage === "production" || Resource.App.stage === "dev")
|
||||
|
||||
const friendIDs = [
|
||||
steamID,
|
||||
...(prod ? settled
|
||||
.filter(result => result.status === "fulfilled")
|
||||
.map(f => f.value)
|
||||
.flat() : [])
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
friendIDs.map(async (currentSteamID) => {
|
||||
// Get user library
|
||||
const gameLibrary = await Client.getUserLibrary(currentSteamID);
|
||||
|
||||
const queryLib = await Promise.allSettled(
|
||||
gameLibrary.response.games.map(async (game) => {
|
||||
await Actor.provide(
|
||||
"steam",
|
||||
{
|
||||
steamID: currentSteamID,
|
||||
},
|
||||
async () => {
|
||||
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
Library.Events.Add,
|
||||
{
|
||||
appID: game.appid,
|
||||
totalPlaytime: game.playtime_forever,
|
||||
lastPlayed: game.rtime_last_played ? new Date(game.rtime_last_played * 1000) : null,
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
queryLib
|
||||
.filter(i => i.status === "rejected")
|
||||
.forEach(e => console.warn(`[pushUserLib]: Failed to push user library to queue: ${e.reason}`))
|
||||
})
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to process Steam data for user ${userID}:`, error);
|
||||
}
|
||||
})())
|
||||
|
||||
return c.html(
|
||||
`
|
||||
<script>
|
||||
window.location.href = "about:blank";
|
||||
window.close()
|
||||
</script>
|
||||
`
|
||||
)
|
||||
}
|
||||
)
|
||||
.get("/popup/:id",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "Login to Steam using QR code",
|
||||
description: "Login to Steam using a QR code sent using Server Sent Events",
|
||||
summary: "Login to Steam",
|
||||
description: "Login to Steam in a popup",
|
||||
responses: {
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"header",
|
||||
"param",
|
||||
z.object({
|
||||
"accept": z.string()
|
||||
.refine((v) =>
|
||||
v.toLowerCase()
|
||||
.includes("text/event-stream")
|
||||
)
|
||||
.openapi({
|
||||
description: "Client must accept Server Sent Events",
|
||||
example: "text/event-stream"
|
||||
})
|
||||
})
|
||||
id: z.string().openapi({
|
||||
description: "ID of the user to login",
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
(c) => {
|
||||
const currentUser = Actor.user()
|
||||
async (c) => {
|
||||
const userID = c.req.valid("param").id;
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const user = await User.fromID(userID);
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
const session = new LoginSession(EAuthTokenPlatformType.MobileApp);
|
||||
setCookie(c, "user_id", user.id);
|
||||
|
||||
session.loginTimeout = 30000; //30 seconds is typically when the url expires
|
||||
const returnUrl = `${new URL(c.req.url).origin}/steam/callback/${userID}`
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "connected to steam" })
|
||||
})
|
||||
const params = new URLSearchParams({
|
||||
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||
'openid.mode': 'checkid_setup',
|
||||
'openid.return_to': returnUrl,
|
||||
'openid.realm': new URL(returnUrl).origin,
|
||||
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'user_id': user.id
|
||||
});
|
||||
|
||||
const challenge = await session.startWithQR();
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'challenge_url',
|
||||
data: JSON.stringify({ url: challenge.qrChallengeUrl })
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
session.on('remoteInteraction', async () => {
|
||||
await stream.writeSSE({
|
||||
event: 'remote_interaction',
|
||||
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Looks like you've scanned the code! Now just approve the login." }),
|
||||
})
|
||||
});
|
||||
|
||||
session.on('timeout', async () => {
|
||||
console.log('This login attempt has timed out.');
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Your session timed out" }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'timed_out',
|
||||
data: JSON.stringify({ success: false }),
|
||||
})
|
||||
|
||||
await stream.close()
|
||||
reject("Authentication timed out")
|
||||
});
|
||||
|
||||
session.on('error', async (err) => {
|
||||
// This should ordinarily not happen. This only happens in case there's some kind of unexpected error while
|
||||
// polling, e.g. the network connection goes down or Steam chokes on something.
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Recieved an error while authenticating" }),
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'error',
|
||||
data: JSON.stringify({ message: err.message }),
|
||||
})
|
||||
|
||||
await stream.close()
|
||||
reject(err.message)
|
||||
});
|
||||
|
||||
|
||||
session.on('authenticated', async () => {
|
||||
await stream.writeSSE({
|
||||
event: 'status',
|
||||
data: JSON.stringify({ message: "Login successful" })
|
||||
})
|
||||
|
||||
await stream.writeSSE({
|
||||
event: 'login_success',
|
||||
data: JSON.stringify({ success: true, })
|
||||
})
|
||||
|
||||
const username = session.accountName;
|
||||
const accessToken = session.accessToken;
|
||||
const refreshToken = session.refreshToken;
|
||||
const steamID = session.steamID.toString();
|
||||
const cookies = await session.getWebCookies();
|
||||
|
||||
// Get user information
|
||||
const community = new SteamCommunity();
|
||||
community.setCookies(cookies);
|
||||
|
||||
const user = await Client.getUserInfo({ steamID, cookies })
|
||||
|
||||
const wasAdded =
|
||||
await Steam.create({
|
||||
username,
|
||||
id: steamID,
|
||||
name: user.name,
|
||||
realName: user.realName,
|
||||
userID: currentUser.userID,
|
||||
avatarHash: user.avatarHash,
|
||||
steamMemberSince: user.memberSince,
|
||||
profileUrl: user.customURL?.trim() || null,
|
||||
limitations: {
|
||||
isLimited: user.isLimitedAccount,
|
||||
isVacBanned: user.vacBanned,
|
||||
privacyState: user.privacyState as any,
|
||||
visibilityState: Number(user.visibilityState),
|
||||
tradeBanState: user.tradeBanState.toLowerCase() as any,
|
||||
}
|
||||
})
|
||||
|
||||
// Does not matter if the user is already there or has just been created, just store the credentials
|
||||
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();
|
||||
|
||||
// create a team
|
||||
teamID = await Team.create({
|
||||
slug: username,
|
||||
name: firstName,
|
||||
ownerID: currentUser.userID,
|
||||
})
|
||||
|
||||
// Add us as a member
|
||||
await Actor.provide(
|
||||
"system",
|
||||
{ teamID },
|
||||
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({
|
||||
event: 'team_slug',
|
||||
data: JSON.stringify({ username })
|
||||
})
|
||||
|
||||
// Get game library in the background
|
||||
c.executionCtx.waitUntil((async () => {
|
||||
const games = await Client.getUserLibrary(accessToken);
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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();
|
||||
})
|
||||
})
|
||||
})
|
||||
return c.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`, 302)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { Resource } from "sst";
|
||||
import { subjects } from "../../subjects";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { type MiddlewareHandler } from "hono";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { Member } from "@nestri/core/member/index";
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
@@ -44,19 +44,19 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
}
|
||||
|
||||
if (result.subject.type === "user") {
|
||||
const teamID = c.req.header("x-nestri-team");
|
||||
if (!teamID) {
|
||||
const steamID = c.req.header("x-nestri-steam");
|
||||
if (!steamID) {
|
||||
return Actor.provide(result.subject.type, result.subject.properties, next);
|
||||
}
|
||||
const userID = result.subject.properties.userID
|
||||
return Actor.provide(
|
||||
"system",
|
||||
"steam",
|
||||
{
|
||||
teamID
|
||||
steamID
|
||||
},
|
||||
async () => {
|
||||
const member = await Member.fromUserID(userID)
|
||||
if (!member || !member.userID) {
|
||||
const steamAcc = await Steam.confirmOwnerShip(userID)
|
||||
if (!steamAcc) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
@@ -66,9 +66,8 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
return Actor.provide(
|
||||
"member",
|
||||
{
|
||||
steamID: member.steamID,
|
||||
userID: member.userID,
|
||||
teamID: member.teamID
|
||||
steamID,
|
||||
userID,
|
||||
},
|
||||
next)
|
||||
});
|
||||
|
||||
@@ -1,74 +1,123 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { Game } from "@nestri/core/game/index";
|
||||
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 { Library } from "@nestri/core/library/index";
|
||||
import { BaseGame } from "@nestri/core/base-game/index";
|
||||
import { Credentials } from "@nestri/core/credentials/index";
|
||||
import { EAuthTokenPlatformType, LoginSession } from "steam-session";
|
||||
import { Categories } from "@nestri/core/categories/index";
|
||||
import { ImageTypeEnum } from "@nestri/core/images/images.sql";
|
||||
import { PutObjectCommand, S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const s3 = new S3Client({});
|
||||
|
||||
export const handler = bus.subscriber(
|
||||
[Credentials.Events.New, BaseGame.Events.New],
|
||||
[
|
||||
Library.Events.Add,
|
||||
BaseGame.Events.New,
|
||||
Steam.Events.Updated,
|
||||
Steam.Events.Created,
|
||||
BaseGame.Events.NewBoxArt,
|
||||
BaseGame.Events.NewHeroArt,
|
||||
],
|
||||
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);
|
||||
case "new_image.save": {
|
||||
const input = event.properties;
|
||||
const image = await Client.getImageInfo({ url: input.url, type: input.type });
|
||||
|
||||
session.refreshToken = credentials.refreshToken;
|
||||
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,
|
||||
});
|
||||
|
||||
const cookies = await session.getWebCookies();
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
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))
|
||||
} 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,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "new_game.added": {
|
||||
const input = event.properties
|
||||
// Get images and save to s3
|
||||
const images = await Client.getImages(input.appID);
|
||||
case "new_box_art_image.save": {
|
||||
const input = event.properties;
|
||||
|
||||
(await Promise.allSettled(
|
||||
const image = await Client.createBoxArt(input);
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "new_hero_art_image.save": {
|
||||
const input = event.properties;
|
||||
|
||||
const images = await Client.createHeroArt(input);
|
||||
|
||||
await Promise.all(
|
||||
images.map(async (image) => {
|
||||
// Put the images into the db
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
@@ -104,14 +153,117 @@ export const handler = bus.subscriber(
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
})
|
||||
))
|
||||
.filter(i => i.status === "rejected")
|
||||
.forEach(r => console.warn("[createImages] failed:", (r as PromiseRejectedResult).reason));
|
||||
)
|
||||
|
||||
break;
|
||||
}
|
||||
case "library.add": {
|
||||
|
||||
await Actor.provide(
|
||||
event.metadata.actor.type,
|
||||
event.metadata.actor.properties,
|
||||
async () => {
|
||||
const game = event.properties
|
||||
// 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);
|
||||
|
||||
await BaseGame.create({
|
||||
id: appID,
|
||||
name: appInfo.name,
|
||||
size: appInfo.size,
|
||||
slug: appInfo.slug,
|
||||
links: appInfo.links,
|
||||
score: appInfo.score,
|
||||
description: appInfo.description,
|
||||
releaseDate: appInfo.releaseDate,
|
||||
primaryGenre: appInfo.primaryGenre,
|
||||
compatibility: appInfo.compatibility,
|
||||
controllerSupport: appInfo.controllerSupport,
|
||||
})
|
||||
|
||||
const allCategories = [...appInfo.tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers, ...appInfo.categories, ...appInfo.franchises]
|
||||
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
uniqueCategories.map(async (cat) => {
|
||||
//Create category if it doesn't exist
|
||||
await Categories.create({
|
||||
type: cat.type, slug: cat.slug, name: cat.name
|
||||
})
|
||||
|
||||
//Create game if it doesn't exist
|
||||
await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type })
|
||||
})
|
||||
)
|
||||
|
||||
const imageUrls = appInfo.images
|
||||
|
||||
await Promise.all(
|
||||
ImageTypeEnum.enumValues.map(async (type) => {
|
||||
switch (type) {
|
||||
case "backdrop": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "backdrop", url: imageUrls.backdrop })
|
||||
break;
|
||||
}
|
||||
case "banner": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "banner", url: imageUrls.banner })
|
||||
break;
|
||||
}
|
||||
case "icon": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "icon", url: imageUrls.icon })
|
||||
break;
|
||||
}
|
||||
case "logo": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "logo", url: imageUrls.logo })
|
||||
break;
|
||||
}
|
||||
case "poster": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.New,
|
||||
{ appID, type: "poster", url: imageUrls.poster }
|
||||
)
|
||||
break;
|
||||
}
|
||||
case "heroArt": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.NewHeroArt,
|
||||
{ appID, backdropUrl: imageUrls.backdrop, screenshots: imageUrls.screenshots }
|
||||
)
|
||||
break;
|
||||
}
|
||||
case "boxArt": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.NewBoxArt,
|
||||
{ appID, logoUrl: imageUrls.logo, backgroundUrl: imageUrls.backdrop }
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Add to user's library
|
||||
await Library.add({
|
||||
baseGameID: appID,
|
||||
lastPlayed: game.lastPlayed ? new Date(game.lastPlayed) : null,
|
||||
totalPlaytime: game.totalPlaytime,
|
||||
})
|
||||
})
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
91
packages/functions/src/images/image.ts
Normal file
91
packages/functions/src/images/image.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Hono } from "hono";
|
||||
import { AwsClient } from 'aws4fetch'
|
||||
import { Resource } from "sst";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
|
||||
export namespace ImageRoute {
|
||||
export const route = new Hono()
|
||||
.get(
|
||||
"/:hashWithExt",
|
||||
async (c) => {
|
||||
// const { hashWithExt } = c.req.param();
|
||||
|
||||
const client = new AwsClient({
|
||||
accessKeyId: Resource.ImageInvokerAccessKey.key,
|
||||
secretAccessKey: Resource.ImageInvokerAccessKey.secret,
|
||||
})
|
||||
|
||||
const LAMBDA_URL = `https://lambda.us-east-1.amazonaws.com/2015-03-31/functions/${Resource.ImageProcessor.name}/invocations`
|
||||
|
||||
const lambdaResponse = await client.fetch(LAMBDA_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ world: "hello" }),
|
||||
})
|
||||
|
||||
if (!lambdaResponse.ok) {
|
||||
console.error(await lambdaResponse.text())
|
||||
return c.json({ error: `Lambda API returned ${lambdaResponse.status}` }, { status: 500 })
|
||||
}
|
||||
|
||||
console.log(await lambdaResponse.json())
|
||||
|
||||
// // Validate format
|
||||
// // Split hash and extension
|
||||
// const match = hashWithExt.match(/^([a-zA-Z0-9_-]+)\.(avif|webp)$/);
|
||||
// if (!match) {
|
||||
// throw new HTTPException(400, { message: "Invalid image hash or format" });
|
||||
// }
|
||||
|
||||
// const [, hash, format] = match;
|
||||
|
||||
// const query = c.req.query();
|
||||
// // Parse dimensions
|
||||
// const width = parseInt(query.w || query.width || "");
|
||||
// const height = parseInt(query.h || query.height || "");
|
||||
// const dpr = parseFloat(query.dpr || "1");
|
||||
|
||||
// if (isNaN(width) || width <= 0) {
|
||||
// throw new HTTPException(400, { message: "Invalid width" });
|
||||
// }
|
||||
// if (!isNaN(height) && height < 0) {
|
||||
// throw new HTTPException(400, { message: "Invalid height" });
|
||||
// }
|
||||
// if (dpr < 1 || dpr > 4) {
|
||||
// throw new HTTPException(400, { message: "Invalid dpr" });
|
||||
// }
|
||||
|
||||
// console.log("url",Resource.Api.url)
|
||||
|
||||
// const imageBytes = await fetch(`${Resource.Api.url}/image/${hash}`,{
|
||||
// method:"POST",
|
||||
// body:JSON.stringify({
|
||||
// dpr,
|
||||
// width,
|
||||
// height,
|
||||
// format
|
||||
// })
|
||||
// })
|
||||
|
||||
// console.log("imahe",imageBytes.headers)
|
||||
|
||||
// // Normalize and build cache key
|
||||
// // const cacheKey = `${hash}_${format}_w${width}${height ? `_h${height}` : ""}_dpr${dpr}`;
|
||||
|
||||
// // Add aggressive caching
|
||||
// // c.header("Cache-Control", "public, max-age=315360000, immutable");
|
||||
|
||||
// // Placeholder image response (to be replaced by real logic)
|
||||
// return c.newResponse(await imageBytes.arrayBuffer(),
|
||||
// // {
|
||||
// // headers: {
|
||||
// // ...imageBytes.headers
|
||||
// // }
|
||||
// // }
|
||||
// );
|
||||
|
||||
return c.text("success")
|
||||
}
|
||||
)
|
||||
}
|
||||
18
packages/functions/src/images/index.ts
Normal file
18
packages/functions/src/images/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import { ImageRoute } from "./image";
|
||||
|
||||
const app = new Hono();
|
||||
app
|
||||
.use(logger(), async (c, next) => {
|
||||
// c.header("Cache-Control", "public, max-age=315360000, immutable");
|
||||
return next();
|
||||
})
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World 👋🏾"))
|
||||
.route("/image", ImageRoute.route)
|
||||
|
||||
|
||||
export type Routes = typeof routes;
|
||||
export default app;
|
||||
5
packages/functions/src/images/processor.ts
Normal file
5
packages/functions/src/images/processor.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export async function handler(event: any) {
|
||||
console.log('Task completion event received:', JSON.stringify(event, null, 2));
|
||||
|
||||
return JSON.stringify({ hello: "world" })
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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));
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
93
packages/functions/src/queues/retry.ts
Normal file
93
packages/functions/src/queues/retry.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Resource } from "sst";
|
||||
import type { SQSHandler } from "aws-lambda";
|
||||
import {
|
||||
SQSClient,
|
||||
SendMessageCommand
|
||||
} from "@aws-sdk/client-sqs";
|
||||
import {
|
||||
LambdaClient,
|
||||
InvokeCommand,
|
||||
GetFunctionCommand,
|
||||
ResourceNotFoundException,
|
||||
} from "@aws-sdk/client-lambda";
|
||||
|
||||
const lambda = new LambdaClient({});
|
||||
lambda.middlewareStack.remove("recursionDetectionMiddleware");
|
||||
const sqs = new SQSClient({});
|
||||
sqs.middlewareStack.remove("recursionDetectionMiddleware");
|
||||
|
||||
export const handler: SQSHandler = async (evt) => {
|
||||
for (const record of evt.Records) {
|
||||
const parsed = JSON.parse(record.body);
|
||||
console.log("body", parsed);
|
||||
const functionName = parsed.requestContext.functionArn
|
||||
.replace(":$LATEST", "")
|
||||
.split(":")
|
||||
.pop();
|
||||
if (parsed.responsePayload) {
|
||||
const attempt = (parsed.requestPayload.attempts || 0) + 1;
|
||||
|
||||
const info = await lambda.send(
|
||||
new GetFunctionCommand({
|
||||
FunctionName: functionName,
|
||||
}),
|
||||
);
|
||||
const max =
|
||||
Number.parseInt(
|
||||
info.Configuration?.Environment?.Variables?.RETRIES || "",
|
||||
) || 0;
|
||||
console.log("max retries", max);
|
||||
if (attempt > max) {
|
||||
console.log(`giving up after ${attempt} retries`);
|
||||
// send to dlq
|
||||
await sqs.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: Resource.Dlq.url,
|
||||
MessageBody: JSON.stringify({
|
||||
requestPayload: parsed.requestPayload,
|
||||
requestContext: parsed.requestContext,
|
||||
responsePayload: parsed.responsePayload,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const seconds = Math.min(Math.pow(2, attempt), 900);
|
||||
console.log(
|
||||
"delaying retry by ",
|
||||
seconds,
|
||||
"seconds for attempt",
|
||||
attempt,
|
||||
);
|
||||
parsed.requestPayload.attempts = attempt;
|
||||
await sqs.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: Resource.RetryQueue.url,
|
||||
DelaySeconds: seconds,
|
||||
MessageBody: JSON.stringify({
|
||||
requestPayload: parsed.requestPayload,
|
||||
requestContext: parsed.requestContext,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed.responsePayload) {
|
||||
console.log("triggering function");
|
||||
try {
|
||||
await lambda.send(
|
||||
new InvokeCommand({
|
||||
InvocationType: "Event",
|
||||
Payload: Buffer.from(JSON.stringify(parsed.requestPayload)),
|
||||
FunctionName: functionName,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ResourceNotFoundException) {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,16 @@ log() {
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
# Ensures user directory ownership
|
||||
chown_user_directory() {
|
||||
local user_group="${USER}:${GID}"
|
||||
if ! chown -R -h --no-preserve-root "$user_group" "${HOME}" 2>/dev/null; then
|
||||
echo "Error: Failed to change ownership of ${HOME} to ${user_group}" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Waits for a given socket to be ready
|
||||
wait_for_socket() {
|
||||
local socket_path="$1"
|
||||
@@ -110,6 +120,13 @@ install_nvidia_driver() {
|
||||
return 0
|
||||
}
|
||||
|
||||
function log_gpu_info {
|
||||
log "Detected GPUs:"
|
||||
for vendor in "${!vendor_devices[@]}"; do
|
||||
log "> $vendor: ${vendor_devices[$vendor]}"
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
# Wait for required sockets
|
||||
wait_for_socket "/run/dbus/system_bus_socket" "DBus" || exit 1
|
||||
@@ -126,10 +143,11 @@ main() {
|
||||
log "Error: Failed to detect GPU information."
|
||||
exit 1
|
||||
}
|
||||
log_gpu_info
|
||||
|
||||
# Handle NVIDIA GPU
|
||||
if [[ -n "${vendor_devices[nvidia]:-}" ]]; then
|
||||
log "NVIDIA GPU detected, applying driver fix..."
|
||||
log "NVIDIA GPU(s) detected, applying driver fix..."
|
||||
|
||||
# Determine NVIDIA driver version
|
||||
local nvidia_driver_version=""
|
||||
@@ -180,8 +198,6 @@ main() {
|
||||
fi
|
||||
}
|
||||
fi
|
||||
else
|
||||
log "No NVIDIA GPU detected, skipping driver fix."
|
||||
fi
|
||||
|
||||
# Make sure gamescope has CAP_SYS_NICE capabilities if available
|
||||
@@ -195,6 +211,10 @@ main() {
|
||||
log "Skipping CAP_SYS_NICE for gamescope, capability not available..."
|
||||
fi
|
||||
|
||||
# Handle user directory permissions
|
||||
log "Ensuring user directory permissions..."
|
||||
chown_user_directory || exit 1
|
||||
|
||||
# Switch to nestri user
|
||||
log "Switching to nestri user for application startup..."
|
||||
if [[ ! -x /etc/nestri/entrypoint_nestri.sh ]]; then
|
||||
|
||||
@@ -5,16 +5,6 @@ log() {
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
# Ensures user directory ownership
|
||||
chown_user_directory() {
|
||||
local user_group="$(id -nu):$(id -ng)"
|
||||
chown -f "$user_group" ~ 2>/dev/null ||
|
||||
sudo chown -f "$user_group" ~ 2>/dev/null ||
|
||||
chown -R -f -h --no-preserve-root "$user_group" ~ 2>/dev/null ||
|
||||
sudo chown -R -f -h --no-preserve-root "$user_group" ~ 2>/dev/null ||
|
||||
log "Warning: Failed to change user directory permissions, there may be permission issues, continuing..."
|
||||
}
|
||||
|
||||
# Parses resolution string
|
||||
parse_resolution() {
|
||||
local resolution="$1"
|
||||
@@ -156,7 +146,6 @@ main_loop() {
|
||||
}
|
||||
|
||||
main() {
|
||||
chown_user_directory
|
||||
load_envs
|
||||
parse_resolution "${RESOLUTION:-1920x1080}" || exit 1
|
||||
restart_chain
|
||||
|
||||
@@ -11,6 +11,3 @@ export PROTON_NO_FSYNC=1
|
||||
|
||||
# Sleeker Mangohud preset :)
|
||||
export MANGOHUD_CONFIG=preset=2
|
||||
|
||||
# Our preferred prefix
|
||||
export WINEPREFIX=/home/${USER}/.nestripfx/
|
||||
|
||||
1
packages/server/.dockerignore
Normal file
1
packages/server/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
1
packages/server/.gitignore
vendored
1
packages/server/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
*.mp4
|
||||
target/
|
||||
529
packages/server/Cargo.lock
generated
529
packages/server/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,17 +10,18 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
gst = { package = "gstreamer", git = "https://gitlab.freedesktop.org/gstreamer/gstreamer-rs", branch = "main", features = ["v1_26"] }
|
||||
gst-webrtc = { package = "gstreamer-webrtc", git = "https://gitlab.freedesktop.org/gstreamer/gstreamer-rs", branch = "main", features = ["v1_26"] }
|
||||
gstrswebrtc = { package = "gst-plugin-webrtc", git = "https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs", branch = "main", features = ["v1_22"] }
|
||||
gstrswebrtc = { package = "gst-plugin-webrtc", git = "https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs", branch = "main" }
|
||||
serde = {version = "1.0", features = ["derive"] }
|
||||
tokio = { version = "1.44", features = ["full"] }
|
||||
clap = { version = "4.5", features = ["env"] }
|
||||
serde_json = "1.0"
|
||||
webrtc = "0.12"
|
||||
webrtc = "0.13"
|
||||
regex = "1.11"
|
||||
rand = "0.9"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
|
||||
log = { version = "0.4", features = ["std"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
chrono = "0.4"
|
||||
futures-util = "0.3"
|
||||
num-derive = "0.4"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use clap::{Arg, Command};
|
||||
use crate::args::encoding_args::AudioCaptureMethod;
|
||||
use crate::enc_helper::{AudioCodec, EncoderType, VideoCodec};
|
||||
use clap::{Arg, Command, value_parser};
|
||||
use clap::builder::{BoolishValueParser, NonEmptyStringValueParser};
|
||||
|
||||
pub mod app_args;
|
||||
pub mod device_args;
|
||||
@@ -19,6 +22,7 @@ impl Args {
|
||||
.long("verbose")
|
||||
.env("VERBOSE")
|
||||
.help("Enable verbose output")
|
||||
.value_parser(BoolishValueParser::new())
|
||||
.default_value("false"),
|
||||
)
|
||||
.arg(
|
||||
@@ -26,7 +30,8 @@ impl Args {
|
||||
.short('d')
|
||||
.long("debug")
|
||||
.env("DEBUG")
|
||||
.help("Enable additional debugging information and features")
|
||||
.help("Enable additional debugging features")
|
||||
.value_parser(BoolishValueParser::new())
|
||||
.default_value("false"),
|
||||
)
|
||||
.arg(
|
||||
@@ -34,6 +39,7 @@ impl Args {
|
||||
.short('u')
|
||||
.long("relay-url")
|
||||
.env("RELAY_URL")
|
||||
.value_parser(NonEmptyStringValueParser::new())
|
||||
.help("Nestri relay URL"),
|
||||
)
|
||||
.arg(
|
||||
@@ -42,6 +48,7 @@ impl Args {
|
||||
.long("resolution")
|
||||
.env("RESOLUTION")
|
||||
.help("Display/stream resolution in 'WxH' format")
|
||||
.value_parser(NonEmptyStringValueParser::new())
|
||||
.default_value("1280x720"),
|
||||
)
|
||||
.arg(
|
||||
@@ -50,6 +57,7 @@ impl Args {
|
||||
.long("framerate")
|
||||
.env("FRAMERATE")
|
||||
.help("Display/stream framerate")
|
||||
.value_parser(value_parser!(u32).range(5..240))
|
||||
.default_value("60"),
|
||||
)
|
||||
.arg(
|
||||
@@ -63,7 +71,7 @@ impl Args {
|
||||
.short('g')
|
||||
.long("gpu-vendor")
|
||||
.env("GPU_VENDOR")
|
||||
.help("GPU to find by vendor (e.g. 'nvidia')")
|
||||
.help("GPU to use by vendor")
|
||||
.required(false),
|
||||
)
|
||||
.arg(
|
||||
@@ -71,7 +79,7 @@ impl Args {
|
||||
.short('n')
|
||||
.long("gpu-name")
|
||||
.env("GPU_NAME")
|
||||
.help("GPU to find by name (e.g. 'rtx 3060')")
|
||||
.help("GPU to use by name")
|
||||
.required(false),
|
||||
)
|
||||
.arg(
|
||||
@@ -79,14 +87,15 @@ impl Args {
|
||||
.short('i')
|
||||
.long("gpu-index")
|
||||
.env("GPU_INDEX")
|
||||
.help("GPU index, if multiple similar GPUs are present")
|
||||
.default_value("0"),
|
||||
.help("GPU to use by index")
|
||||
.value_parser(value_parser!(i32).range(-1..))
|
||||
.default_value("-1")
|
||||
)
|
||||
.arg(
|
||||
Arg::new("gpu-card-path")
|
||||
.long("gpu-card-path")
|
||||
.env("GPU_CARD_PATH")
|
||||
.help("Force a specific GPU by card/render path (e.g. '/dev/dri/card0')")
|
||||
.help("Force a specific GPU by /dev/dri/ card or render path")
|
||||
.required(false)
|
||||
.conflicts_with_all(["gpu-vendor", "gpu-name", "gpu-index"]),
|
||||
)
|
||||
@@ -95,27 +104,30 @@ impl Args {
|
||||
.short('c')
|
||||
.long("video-codec")
|
||||
.env("VIDEO_CODEC")
|
||||
.help("Preferred video codec ('h264', 'h265', 'av1')")
|
||||
.help("Preferred video codec")
|
||||
.value_parser(value_parser!(VideoCodec))
|
||||
.default_value("h264"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("video-encoder")
|
||||
.long("video-encoder")
|
||||
.env("VIDEO_ENCODER")
|
||||
.help("Override video encoder (e.g. 'vah264enc')"),
|
||||
.help("Override video encoder"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("video-rate-control")
|
||||
.long("video-rate-control")
|
||||
.env("VIDEO_RATE_CONTROL")
|
||||
.help("Rate control method ('cqp', 'vbr', 'cbr')")
|
||||
.default_value("vbr"),
|
||||
.help("Rate control method")
|
||||
.value_parser(value_parser!(encoding_args::RateControlMethod))
|
||||
.default_value("cbr"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("video-cqp")
|
||||
.long("video-cqp")
|
||||
.env("VIDEO_CQP")
|
||||
.help("Constant Quantization Parameter (CQP) quality")
|
||||
.value_parser(value_parser!(u32).range(1..51))
|
||||
.default_value("26"),
|
||||
)
|
||||
.arg(
|
||||
@@ -123,6 +135,7 @@ impl Args {
|
||||
.long("video-bitrate")
|
||||
.env("VIDEO_BITRATE")
|
||||
.help("Target bitrate in kbps")
|
||||
.value_parser(value_parser!(u32).range(1..))
|
||||
.default_value("6000"),
|
||||
)
|
||||
.arg(
|
||||
@@ -130,27 +143,31 @@ impl Args {
|
||||
.long("video-bitrate-max")
|
||||
.env("VIDEO_BITRATE_MAX")
|
||||
.help("Maximum bitrate in kbps")
|
||||
.value_parser(value_parser!(u32).range(1..))
|
||||
.default_value("8000"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("video-encoder-type")
|
||||
.long("video-encoder-type")
|
||||
.env("VIDEO_ENCODER_TYPE")
|
||||
.help("Encoder type ('hardware', 'software')")
|
||||
.help("Encoder type")
|
||||
.value_parser(value_parser!(EncoderType))
|
||||
.default_value("hardware"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("audio-capture-method")
|
||||
.long("audio-capture-method")
|
||||
.env("AUDIO_CAPTURE_METHOD")
|
||||
.help("Audio capture method ('pipewire', 'pulseaudio', 'alsa')")
|
||||
.default_value("pulseaudio"),
|
||||
.help("Audio capture method")
|
||||
.value_parser(value_parser!(AudioCaptureMethod))
|
||||
.default_value("pipewire"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("audio-codec")
|
||||
.long("audio-codec")
|
||||
.env("AUDIO_CODEC")
|
||||
.help("Preferred audio codec ('opus', 'aac')")
|
||||
.help("Preferred audio codec")
|
||||
.value_parser(value_parser!(AudioCodec))
|
||||
.default_value("opus"),
|
||||
)
|
||||
.arg(
|
||||
@@ -163,14 +180,16 @@ impl Args {
|
||||
Arg::new("audio-rate-control")
|
||||
.long("audio-rate-control")
|
||||
.env("AUDIO_RATE_CONTROL")
|
||||
.help("Rate control method ('cqp', 'vbr', 'cbr')")
|
||||
.default_value("vbr"),
|
||||
.help("Rate control method")
|
||||
.value_parser(value_parser!(encoding_args::RateControlMethod))
|
||||
.default_value("cbr"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("audio-bitrate")
|
||||
.long("audio-bitrate")
|
||||
.env("AUDIO_BITRATE")
|
||||
.help("Target bitrate in kbps")
|
||||
.value_parser(value_parser!(u32).range(1..))
|
||||
.default_value("128"),
|
||||
)
|
||||
.arg(
|
||||
@@ -178,6 +197,7 @@ impl Args {
|
||||
.long("audio-bitrate-max")
|
||||
.env("AUDIO_BITRATE_MAX")
|
||||
.help("Maximum bitrate in kbps")
|
||||
.value_parser(value_parser!(u32).range(1..))
|
||||
.default_value("192"),
|
||||
)
|
||||
.arg(
|
||||
@@ -185,6 +205,7 @@ impl Args {
|
||||
.long("dma-buf")
|
||||
.env("DMA_BUF")
|
||||
.help("Use DMA-BUF for pipeline")
|
||||
.value_parser(BoolishValueParser::new())
|
||||
.default_value("false"),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
@@ -20,10 +20,8 @@ pub struct AppArgs {
|
||||
impl AppArgs {
|
||||
pub fn from_matches(matches: &clap::ArgMatches) -> Self {
|
||||
Self {
|
||||
verbose: matches.get_one::<String>("verbose").unwrap() == "true"
|
||||
|| matches.get_one::<String>("verbose").unwrap() == "1",
|
||||
debug: matches.get_one::<String>("debug").unwrap() == "true"
|
||||
|| matches.get_one::<String>("debug").unwrap() == "1",
|
||||
verbose: matches.get_one::<bool>("verbose").unwrap_or(&false).clone(),
|
||||
debug: matches.get_one::<bool>("debug").unwrap_or(&false).clone(),
|
||||
resolution: {
|
||||
let res = matches
|
||||
.get_one::<String>("resolution")
|
||||
@@ -39,11 +37,7 @@ impl AppArgs {
|
||||
(1280, 720)
|
||||
}
|
||||
},
|
||||
framerate: matches
|
||||
.get_one::<String>("framerate")
|
||||
.unwrap()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(60),
|
||||
framerate: matches.get_one::<u32>("framerate").unwrap_or(&60).clone(),
|
||||
relay_url: matches
|
||||
.get_one::<String>("relay-url")
|
||||
.expect("relay url cannot be empty")
|
||||
@@ -53,19 +47,18 @@ impl AppArgs {
|
||||
.get_one::<String>("room")
|
||||
.unwrap_or(&rand::random::<u32>().to_string())
|
||||
.clone(),
|
||||
dma_buf: matches.get_one::<String>("dma-buf").unwrap() == "true"
|
||||
|| matches.get_one::<String>("dma-buf").unwrap() == "1",
|
||||
dma_buf: matches.get_one::<bool>("dma-buf").unwrap_or(&false).clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_print(&self) {
|
||||
println!("AppArgs:");
|
||||
println!("> verbose: {}", self.verbose);
|
||||
println!("> debug: {}", self.debug);
|
||||
println!("> resolution: {}x{}", self.resolution.0, self.resolution.1);
|
||||
println!("> framerate: {}", self.framerate);
|
||||
println!("> relay_url: {}", self.relay_url);
|
||||
println!("> room: {}", self.room);
|
||||
println!("> dma_buf: {}", self.dma_buf);
|
||||
tracing::info!("AppArgs:");
|
||||
tracing::info!("> verbose: {}", self.verbose);
|
||||
tracing::info!("> debug: {}", self.debug);
|
||||
tracing::info!("> resolution: '{}x{}'", self.resolution.0, self.resolution.1);
|
||||
tracing::info!("> framerate: {}", self.framerate);
|
||||
tracing::info!("> relay_url: '{}'", self.relay_url);
|
||||
tracing::info!("> room: '{}'", self.room);
|
||||
tracing::info!("> dma_buf: {}", self.dma_buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ pub struct DeviceArgs {
|
||||
pub gpu_vendor: String,
|
||||
/// GPU name (e.g. "a770")
|
||||
pub gpu_name: String,
|
||||
/// GPU index, if multiple same GPUs are present
|
||||
pub gpu_index: u32,
|
||||
/// GPU index, if multiple same GPUs are present, -1 for auto-selection
|
||||
pub gpu_index: i32,
|
||||
/// GPU card/render path, sets card explicitly from such path
|
||||
pub gpu_card_path: String,
|
||||
}
|
||||
@@ -20,10 +20,9 @@ impl DeviceArgs {
|
||||
.unwrap_or(&"".to_string())
|
||||
.clone(),
|
||||
gpu_index: matches
|
||||
.get_one::<String>("gpu-index")
|
||||
.unwrap()
|
||||
.parse::<u32>()
|
||||
.unwrap(),
|
||||
.get_one::<i32>("gpu-index")
|
||||
.unwrap_or(&-1)
|
||||
.clone(),
|
||||
gpu_card_path: matches
|
||||
.get_one::<String>("gpu-card-path")
|
||||
.unwrap_or(&"".to_string())
|
||||
@@ -32,17 +31,10 @@ impl DeviceArgs {
|
||||
}
|
||||
|
||||
pub fn debug_print(&self) {
|
||||
println!("DeviceArgs:");
|
||||
println!("> gpu_vendor: {}", self.gpu_vendor);
|
||||
println!("> gpu_name: {}", self.gpu_name);
|
||||
println!("> gpu_index: {}", self.gpu_index);
|
||||
println!(
|
||||
"> gpu_card_path: {}",
|
||||
if self.gpu_card_path.is_empty() {
|
||||
"Auto-Selection"
|
||||
} else {
|
||||
&self.gpu_card_path
|
||||
}
|
||||
);
|
||||
tracing::info!("DeviceArgs:");
|
||||
tracing::info!("> gpu_vendor: '{}'", self.gpu_vendor);
|
||||
tracing::info!("> gpu_name: '{}'", self.gpu_name);
|
||||
tracing::info!("> gpu_index: {}", self.gpu_index);
|
||||
tracing::info!("> gpu_card_path: '{}'", self.gpu_card_path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use crate::enc_helper::Codec::{Audio, Video};
|
||||
use crate::enc_helper::{AudioCodec, Codec, EncoderType, VideoCodec};
|
||||
use clap::ValueEnum;
|
||||
use std::ops::Deref;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct RateControlCQP {
|
||||
@@ -8,14 +12,42 @@ pub struct RateControlCQP {
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct RateControlVBR {
|
||||
/// Target bitrate in kbps
|
||||
pub target_bitrate: i32,
|
||||
pub target_bitrate: u32,
|
||||
/// Maximum bitrate in kbps
|
||||
pub max_bitrate: i32,
|
||||
pub max_bitrate: u32,
|
||||
}
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct RateControlCBR {
|
||||
/// Target bitrate in kbps
|
||||
pub target_bitrate: i32,
|
||||
pub target_bitrate: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, ValueEnum)]
|
||||
pub enum RateControlMethod {
|
||||
CQP,
|
||||
VBR,
|
||||
CBR,
|
||||
}
|
||||
impl RateControlMethod {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
RateControlMethod::CQP => "cqp",
|
||||
RateControlMethod::VBR => "vbr",
|
||||
RateControlMethod::CBR => "cbr",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FromStr for RateControlMethod {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"cqp" => Ok(RateControlMethod::CQP),
|
||||
"vbr" => Ok(RateControlMethod::VBR),
|
||||
"cbr" => Ok(RateControlMethod::CBR),
|
||||
_ => Err(format!("Invalid rate control method: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
@@ -30,7 +62,7 @@ pub enum RateControl {
|
||||
|
||||
pub struct EncodingOptionsBase {
|
||||
/// Codec (e.g. "h264", "opus" etc.)
|
||||
pub codec: String,
|
||||
pub codec: Codec,
|
||||
/// Overridable encoder (e.g. "vah264lpenc", "opusenc" etc.)
|
||||
pub encoder: String,
|
||||
/// Rate control method (e.g. "cqp", "vbr", "cbr")
|
||||
@@ -38,28 +70,21 @@ pub struct EncodingOptionsBase {
|
||||
}
|
||||
impl EncodingOptionsBase {
|
||||
pub fn debug_print(&self) {
|
||||
println!("> Codec: {}", self.codec);
|
||||
println!(
|
||||
"> Encoder: {}",
|
||||
if self.encoder.is_empty() {
|
||||
"Auto-Selection"
|
||||
} else {
|
||||
&self.encoder
|
||||
}
|
||||
);
|
||||
tracing::info!("> Codec: '{}'", self.codec.as_str());
|
||||
tracing::info!("> Encoder: '{}'", self.encoder);
|
||||
match &self.rate_control {
|
||||
RateControl::CQP(cqp) => {
|
||||
println!("> Rate Control: CQP");
|
||||
println!("-> Quality: {}", cqp.quality);
|
||||
tracing::info!("> Rate Control: CQP");
|
||||
tracing::info!("-> Quality: {}", cqp.quality);
|
||||
}
|
||||
RateControl::VBR(vbr) => {
|
||||
println!("> Rate Control: VBR");
|
||||
println!("-> Target Bitrate: {}", vbr.target_bitrate);
|
||||
println!("-> Max Bitrate: {}", vbr.max_bitrate);
|
||||
tracing::info!("> Rate Control: VBR");
|
||||
tracing::info!("-> Target Bitrate: {}", vbr.target_bitrate);
|
||||
tracing::info!("-> Max Bitrate: {}", vbr.max_bitrate);
|
||||
}
|
||||
RateControl::CBR(cbr) => {
|
||||
println!("> Rate Control: CBR");
|
||||
println!("-> Target Bitrate: {}", cbr.target_bitrate);
|
||||
tracing::info!("> Rate Control: CBR");
|
||||
tracing::info!("-> Target Bitrate: {}", cbr.target_bitrate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,63 +92,62 @@ impl EncodingOptionsBase {
|
||||
|
||||
pub struct VideoEncodingOptions {
|
||||
pub base: EncodingOptionsBase,
|
||||
/// Encoder type (e.g. "hardware", "software")
|
||||
pub encoder_type: String,
|
||||
pub encoder_type: EncoderType,
|
||||
}
|
||||
impl VideoEncodingOptions {
|
||||
pub fn from_matches(matches: &clap::ArgMatches) -> Self {
|
||||
Self {
|
||||
base: EncodingOptionsBase {
|
||||
codec: matches.get_one::<String>("video-codec").unwrap().clone(),
|
||||
codec: Video(
|
||||
matches
|
||||
.get_one::<VideoCodec>("video-codec")
|
||||
.unwrap_or(&VideoCodec::H264)
|
||||
.clone(),
|
||||
),
|
||||
encoder: matches
|
||||
.get_one::<String>("video-encoder")
|
||||
.unwrap_or(&"".to_string())
|
||||
.clone(),
|
||||
rate_control: match matches
|
||||
.get_one::<String>("video-rate-control")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.get_one::<RateControlMethod>("video-rate-control")
|
||||
.unwrap_or(&RateControlMethod::CBR)
|
||||
{
|
||||
"cqp" => RateControl::CQP(RateControlCQP {
|
||||
RateControlMethod::CQP => RateControl::CQP(RateControlCQP {
|
||||
quality: matches
|
||||
.get_one::<String>("video-cqp")
|
||||
.unwrap()
|
||||
.parse::<u32>()
|
||||
.unwrap(),
|
||||
}),
|
||||
"cbr" => RateControl::CBR(RateControlCBR {
|
||||
RateControlMethod::CBR => RateControl::CBR(RateControlCBR {
|
||||
target_bitrate: matches
|
||||
.get_one::<String>("video-bitrate")
|
||||
.get_one::<u32>("video-bitrate")
|
||||
.unwrap()
|
||||
.parse::<i32>()
|
||||
.unwrap(),
|
||||
.clone(),
|
||||
}),
|
||||
"vbr" => RateControl::VBR(RateControlVBR {
|
||||
RateControlMethod::VBR => RateControl::VBR(RateControlVBR {
|
||||
target_bitrate: matches
|
||||
.get_one::<String>("video-bitrate")
|
||||
.get_one::<u32>("video-bitrate")
|
||||
.unwrap()
|
||||
.parse::<i32>()
|
||||
.unwrap(),
|
||||
.clone(),
|
||||
max_bitrate: matches
|
||||
.get_one::<String>("video-bitrate-max")
|
||||
.get_one::<u32>("video-bitrate-max")
|
||||
.unwrap()
|
||||
.parse::<i32>()
|
||||
.unwrap(),
|
||||
.clone(),
|
||||
}),
|
||||
_ => panic!("Invalid rate control method for video"),
|
||||
},
|
||||
},
|
||||
encoder_type: matches
|
||||
.get_one::<String>("video-encoder-type")
|
||||
.unwrap_or(&"hardware".to_string())
|
||||
.get_one::<EncoderType>("video-encoder-type")
|
||||
.unwrap_or(&EncoderType::HARDWARE)
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_print(&self) {
|
||||
println!("Video Encoding Options:");
|
||||
tracing::info!("Video Encoding Options:");
|
||||
self.base.debug_print();
|
||||
println!("> Encoder Type: {}", self.encoder_type);
|
||||
tracing::info!("> Encoder Type: {}", self.encoder_type.as_str());
|
||||
}
|
||||
}
|
||||
impl Deref for VideoEncodingOptions {
|
||||
@@ -134,18 +158,30 @@ impl Deref for VideoEncodingOptions {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, ValueEnum)]
|
||||
pub enum AudioCaptureMethod {
|
||||
PulseAudio,
|
||||
PipeWire,
|
||||
PULSEAUDIO,
|
||||
PIPEWIRE,
|
||||
ALSA,
|
||||
}
|
||||
impl AudioCaptureMethod {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
AudioCaptureMethod::PulseAudio => "pulseaudio",
|
||||
AudioCaptureMethod::PipeWire => "pipewire",
|
||||
AudioCaptureMethod::ALSA => "alsa",
|
||||
AudioCaptureMethod::PULSEAUDIO => "PulseAudio",
|
||||
AudioCaptureMethod::PIPEWIRE => "PipeWire",
|
||||
AudioCaptureMethod::ALSA => "ALSA",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FromStr for AudioCaptureMethod {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"pulseaudio" => Ok(AudioCaptureMethod::PULSEAUDIO),
|
||||
"pipewire" => Ok(AudioCaptureMethod::PIPEWIRE),
|
||||
"alsa" => Ok(AudioCaptureMethod::ALSA),
|
||||
_ => Err(format!("Invalid audio capture method: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,56 +194,50 @@ impl AudioEncodingOptions {
|
||||
pub fn from_matches(matches: &clap::ArgMatches) -> Self {
|
||||
Self {
|
||||
base: EncodingOptionsBase {
|
||||
codec: matches.get_one::<String>("audio-codec").unwrap().clone(),
|
||||
codec: Audio(
|
||||
matches
|
||||
.get_one::<AudioCodec>("audio-codec")
|
||||
.unwrap_or(&AudioCodec::OPUS)
|
||||
.clone(),
|
||||
),
|
||||
encoder: matches
|
||||
.get_one::<String>("audio-encoder")
|
||||
.unwrap_or(&"".to_string())
|
||||
.clone(),
|
||||
rate_control: match matches
|
||||
.get_one::<String>("audio-rate-control")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.get_one::<RateControlMethod>("audio-rate-control")
|
||||
.unwrap_or(&RateControlMethod::CBR)
|
||||
{
|
||||
"cbr" => RateControl::CBR(RateControlCBR {
|
||||
RateControlMethod::CBR => RateControl::CBR(RateControlCBR {
|
||||
target_bitrate: matches
|
||||
.get_one::<String>("audio-bitrate")
|
||||
.get_one::<u32>("audio-bitrate")
|
||||
.unwrap()
|
||||
.parse::<i32>()
|
||||
.unwrap(),
|
||||
.clone(),
|
||||
}),
|
||||
"vbr" => RateControl::VBR(RateControlVBR {
|
||||
RateControlMethod::VBR => RateControl::VBR(RateControlVBR {
|
||||
target_bitrate: matches
|
||||
.get_one::<String>("audio-bitrate")
|
||||
.get_one::<u32>("audio-bitrate")
|
||||
.unwrap()
|
||||
.parse::<i32>()
|
||||
.unwrap(),
|
||||
.clone(),
|
||||
max_bitrate: matches
|
||||
.get_one::<String>("audio-bitrate-max")
|
||||
.get_one::<u32>("audio-bitrate-max")
|
||||
.unwrap()
|
||||
.parse::<i32>()
|
||||
.unwrap(),
|
||||
.clone(),
|
||||
}),
|
||||
_ => panic!("Invalid rate control method for audio"),
|
||||
wot => panic!("Invalid rate control method for audio: {}", wot.as_str()),
|
||||
},
|
||||
},
|
||||
capture_method: match matches
|
||||
.get_one::<String>("audio-capture-method")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
{
|
||||
"pulseaudio" => AudioCaptureMethod::PulseAudio,
|
||||
"pipewire" => AudioCaptureMethod::PipeWire,
|
||||
"alsa" => AudioCaptureMethod::ALSA,
|
||||
// Default to PulseAudio
|
||||
_ => AudioCaptureMethod::PulseAudio,
|
||||
},
|
||||
capture_method: matches
|
||||
.get_one::<AudioCaptureMethod>("audio-capture-method")
|
||||
.unwrap_or(&AudioCaptureMethod::PIPEWIRE)
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_print(&self) {
|
||||
println!("Audio Encoding Options:");
|
||||
tracing::info!("Audio Encoding Options:");
|
||||
self.base.debug_print();
|
||||
println!("> Capture Method: {}", self.capture_method.as_str());
|
||||
tracing::info!("> Capture Method: {}", self.capture_method.as_str());
|
||||
}
|
||||
}
|
||||
impl Deref for AudioEncodingOptions {
|
||||
@@ -233,7 +263,7 @@ impl EncodingArgs {
|
||||
}
|
||||
|
||||
pub fn debug_print(&self) {
|
||||
println!("Encoding Arguments:");
|
||||
tracing::info!("Encoding Arguments:");
|
||||
self.video.debug_print();
|
||||
self.audio.debug_print();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,70 @@
|
||||
use crate::args::encoding_args::RateControl;
|
||||
use crate::gpu::{self, GPUInfo, get_gpu_by_card_path, get_gpus_by_vendor};
|
||||
use clap::ValueEnum;
|
||||
use gst::prelude::*;
|
||||
use std::error::Error;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||
#[derive(Debug, Eq, PartialEq, Clone, ValueEnum)]
|
||||
pub enum AudioCodec {
|
||||
OPUS,
|
||||
}
|
||||
impl AudioCodec {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OPUS => "Opus",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FromStr for AudioCodec {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"opus" => Ok(Self::OPUS),
|
||||
_ => Err(format!("Invalid audio codec: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, ValueEnum)]
|
||||
pub enum VideoCodec {
|
||||
H264,
|
||||
H265,
|
||||
AV1,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
impl VideoCodec {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::H264 => "H.264",
|
||||
Self::H265 => "H.265",
|
||||
Self::AV1 => "AV1",
|
||||
Self::UNKNOWN => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FromStr for VideoCodec {
|
||||
type Err = String;
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"h264" | "h.264" | "avc" => Self::H264,
|
||||
"h265" | "h.265" | "hevc" | "hev1" => Self::H265,
|
||||
"av1" => Self::AV1,
|
||||
_ => Self::UNKNOWN,
|
||||
"h264" | "h.264" | "avc" => Ok(Self::H264),
|
||||
"h265" | "h.265" | "hevc" | "hev1" => Ok(Self::H265),
|
||||
"av1" => Ok(Self::AV1),
|
||||
_ => Err(format!("Invalid video codec: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||
pub enum Codec {
|
||||
Audio(AudioCodec),
|
||||
Video(VideoCodec),
|
||||
}
|
||||
impl Codec {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Audio(codec) => codec.as_str(),
|
||||
Self::Video(codec) => codec.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,27 +92,17 @@ impl EncoderAPI {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||
#[derive(Debug, Eq, PartialEq, Clone, ValueEnum)]
|
||||
pub enum EncoderType {
|
||||
SOFTWARE,
|
||||
HARDWARE,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
impl EncoderType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SOFTWARE => "Software",
|
||||
Self::HARDWARE => "Hardware",
|
||||
Self::UNKNOWN => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"software" => Self::SOFTWARE,
|
||||
"hardware" => Self::HARDWARE,
|
||||
_ => Self::UNKNOWN,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +150,7 @@ impl VideoEncoderInfo {
|
||||
for (key, value) in &self.parameters {
|
||||
if element.has_property(key) {
|
||||
if verbose {
|
||||
println!("Setting property {} to {}", key, value);
|
||||
tracing::debug!("Setting property {} to {}", key, value);
|
||||
}
|
||||
element.set_property_from_str(key, value);
|
||||
}
|
||||
@@ -145,19 +174,15 @@ fn get_encoder_api(encoder: &str, encoder_type: &EncoderType) -> EncoderAPI {
|
||||
}
|
||||
}
|
||||
EncoderType::SOFTWARE => EncoderAPI::SOFTWARE,
|
||||
_ => EncoderAPI::UNKNOWN,
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_from_encoder_name(name: &str) -> Option<VideoCodec> {
|
||||
if name.contains("h264") {
|
||||
Some(VideoCodec::H264)
|
||||
} else if name.contains("h265") {
|
||||
Some(VideoCodec::H265)
|
||||
} else if name.contains("av1") {
|
||||
Some(VideoCodec::AV1)
|
||||
} else {
|
||||
None
|
||||
match name.to_lowercase() {
|
||||
n if n.contains("h264") => Some(VideoCodec::H264),
|
||||
n if n.contains("h265") => Some(VideoCodec::H265),
|
||||
n if n.contains("av1") => Some(VideoCodec::AV1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +297,6 @@ pub fn encoder_low_latency_params(
|
||||
let usage = match encoder_optz.codec {
|
||||
VideoCodec::H264 | VideoCodec::H265 => "ultra-low-latency",
|
||||
VideoCodec::AV1 => "low-latency",
|
||||
_ => "",
|
||||
};
|
||||
if !usage.is_empty() {
|
||||
encoder_optz.set_parameter("usage", usage);
|
||||
@@ -378,8 +402,8 @@ pub fn get_compatible_encoders() -> Vec<VideoEncoderInfo> {
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
log::error!(
|
||||
"Panic occurred while querying properties for {}",
|
||||
tracing::error!(
|
||||
"Error occurred while querying properties for {}",
|
||||
encoder_name
|
||||
);
|
||||
None
|
||||
@@ -401,16 +425,20 @@ pub fn get_compatible_encoders() -> Vec<VideoEncoderInfo> {
|
||||
/// * `encoders` - A vector containing information about each encoder.
|
||||
/// * `name` - A string slice that holds the encoder name.
|
||||
/// # Returns
|
||||
/// * `Option<EncoderInfo>` - A reference to an EncoderInfo struct if found.
|
||||
/// * `Result<EncoderInfo, Box<dyn Error>>` - A Result containing EncoderInfo if found, or an error.
|
||||
pub fn get_encoder_by_name(
|
||||
encoders: &Vec<VideoEncoderInfo>,
|
||||
name: &str,
|
||||
) -> Option<VideoEncoderInfo> {
|
||||
) -> Result<VideoEncoderInfo, Box<dyn Error>> {
|
||||
let name = name.to_lowercase();
|
||||
encoders
|
||||
if let Some(encoder) = encoders
|
||||
.iter()
|
||||
.find(|encoder| encoder.name.to_lowercase() == name)
|
||||
.cloned()
|
||||
{
|
||||
Ok(encoder.clone())
|
||||
} else {
|
||||
Err(format!("Encoder '{}' not found", name).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to get encoders from vector by video codec.
|
||||
@@ -453,15 +481,23 @@ pub fn get_encoders_by_type(
|
||||
/// * `codec` - Desired codec.
|
||||
/// * `encoder_type` - Desired encoder type.
|
||||
/// # Returns
|
||||
/// * `Option<EncoderInfo>` - Best-case compatible encoder.
|
||||
/// * `Result<VideoEncoderInfo, Box<dyn Error>>` - A Result containing the best compatible encoder if found, or an error.
|
||||
pub fn get_best_compatible_encoder(
|
||||
encoders: &Vec<VideoEncoderInfo>,
|
||||
codec: VideoCodec,
|
||||
encoder_type: EncoderType,
|
||||
) -> Option<VideoEncoderInfo> {
|
||||
codec: &Codec,
|
||||
encoder_type: &EncoderType,
|
||||
) -> Result<VideoEncoderInfo, Box<dyn Error>> {
|
||||
let mut best_encoder: Option<VideoEncoderInfo> = None;
|
||||
let mut best_score: i32 = 0;
|
||||
|
||||
let codec = match codec {
|
||||
Codec::Video(c) => c.clone(),
|
||||
Codec::Audio(_) => {
|
||||
// Only for video currently
|
||||
return Err("Attempted to get best compatible video encoder with audio codec".into());
|
||||
}
|
||||
};
|
||||
|
||||
// Filter by codec and type first
|
||||
let encoders = get_encoders_by_videocodec(encoders, &codec);
|
||||
let encoders = get_encoders_by_type(&encoders, &encoder_type);
|
||||
@@ -498,5 +534,9 @@ pub fn get_best_compatible_encoder(
|
||||
}
|
||||
}
|
||||
|
||||
best_encoder
|
||||
if let Some(encoder) = best_encoder {
|
||||
Ok(encoder)
|
||||
} else {
|
||||
Err("No compatible encoder found".into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,3 +161,11 @@ pub fn get_gpu_by_card_path(gpus: &[GPUInfo], path: &str) -> Option<GPUInfo> {
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn get_gpu_by_index(gpus: &[GPUInfo], index: i32) -> Option<GPUInfo> {
|
||||
if index < 0 || index as usize >= gpus.len() {
|
||||
None
|
||||
} else {
|
||||
Some(gpus[index as usize].clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod proto;
|
||||
mod websocket;
|
||||
|
||||
use crate::args::encoding_args;
|
||||
use crate::enc_helper::EncoderType;
|
||||
use crate::gpu::GPUVendor;
|
||||
use crate::nestrisink::NestriSignaller;
|
||||
use crate::websocket::NestriWebSocket;
|
||||
@@ -20,16 +21,16 @@ use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Handles gathering GPU information and selecting the most suitable GPU
|
||||
fn handle_gpus(args: &args::Args) -> Option<gpu::GPUInfo> {
|
||||
println!("Gathering GPU information..");
|
||||
fn handle_gpus(args: &args::Args) -> Result<gpu::GPUInfo, Box<dyn Error>> {
|
||||
tracing::info!("Gathering GPU information..");
|
||||
let gpus = gpu::get_gpus();
|
||||
if gpus.is_empty() {
|
||||
println!("No GPUs found");
|
||||
return None;
|
||||
return Err("No GPUs found".into());
|
||||
}
|
||||
for gpu in &gpus {
|
||||
println!(
|
||||
"> [GPU] Vendor: '{}', Card Path: '{}', Render Path: '{}', Device Name: '{}'",
|
||||
for (i, gpu) in gpus.iter().enumerate() {
|
||||
tracing::info!(
|
||||
"> [GPU:{}] Vendor: '{}', Card Path: '{}', Render Path: '{}', Device Name: '{}'",
|
||||
i,
|
||||
gpu.vendor_string(),
|
||||
gpu.card_path(),
|
||||
gpu.render_path(),
|
||||
@@ -50,9 +51,12 @@ fn handle_gpus(args: &args::Args) -> Option<gpu::GPUInfo> {
|
||||
if !args.device.gpu_name.is_empty() {
|
||||
filtered_gpus = gpu::get_gpus_by_device_name(&filtered_gpus, &args.device.gpu_name);
|
||||
}
|
||||
if args.device.gpu_index != 0 {
|
||||
if args.device.gpu_index > -1 {
|
||||
// get single GPU by index
|
||||
gpu = filtered_gpus.get(args.device.gpu_index as usize).cloned();
|
||||
gpu = gpu::get_gpu_by_index(&filtered_gpus, args.device.gpu_index).or_else(|| {
|
||||
tracing::warn!("GPU index {} is out of range", args.device.gpu_index);
|
||||
None
|
||||
});
|
||||
} else {
|
||||
// get first GPU
|
||||
gpu = filtered_gpus
|
||||
@@ -61,35 +65,33 @@ fn handle_gpus(args: &args::Args) -> Option<gpu::GPUInfo> {
|
||||
}
|
||||
}
|
||||
if gpu.is_none() {
|
||||
println!(
|
||||
return Err(format!(
|
||||
"No GPU found with the specified parameters: vendor='{}', name='{}', index='{}', card_path='{}'",
|
||||
args.device.gpu_vendor,
|
||||
args.device.gpu_name,
|
||||
args.device.gpu_index,
|
||||
args.device.gpu_card_path
|
||||
);
|
||||
return None;
|
||||
).into());
|
||||
}
|
||||
let gpu = gpu.unwrap();
|
||||
println!("Selected GPU: '{}'", gpu.device_name());
|
||||
Some(gpu)
|
||||
tracing::info!("Selected GPU: '{}'", gpu.device_name());
|
||||
Ok(gpu)
|
||||
}
|
||||
|
||||
// Handles picking video encoder
|
||||
fn handle_encoder_video(args: &args::Args) -> Option<enc_helper::VideoEncoderInfo> {
|
||||
println!("Getting compatible video encoders..");
|
||||
fn handle_encoder_video(args: &args::Args) -> Result<enc_helper::VideoEncoderInfo, Box<dyn Error>> {
|
||||
tracing::info!("Getting compatible video encoders..");
|
||||
let video_encoders = enc_helper::get_compatible_encoders();
|
||||
if video_encoders.is_empty() {
|
||||
println!("No compatible video encoders found");
|
||||
return None;
|
||||
return Err("No compatible video encoders found".into());
|
||||
}
|
||||
for encoder in &video_encoders {
|
||||
println!(
|
||||
tracing::info!(
|
||||
"> [Video Encoder] Name: '{}', Codec: '{}', API: '{}', Type: '{}', Device: '{}'",
|
||||
encoder.name,
|
||||
encoder.codec.to_str(),
|
||||
encoder.codec.as_str(),
|
||||
encoder.encoder_api.to_str(),
|
||||
encoder.encoder_type.to_str(),
|
||||
encoder.encoder_type.as_str(),
|
||||
if let Some(gpu) = &encoder.gpu_info {
|
||||
gpu.device_name()
|
||||
} else {
|
||||
@@ -101,26 +103,16 @@ fn handle_encoder_video(args: &args::Args) -> Option<enc_helper::VideoEncoderInf
|
||||
let video_encoder;
|
||||
if !args.encoding.video.encoder.is_empty() {
|
||||
video_encoder =
|
||||
enc_helper::get_encoder_by_name(&video_encoders, &args.encoding.video.encoder);
|
||||
enc_helper::get_encoder_by_name(&video_encoders, &args.encoding.video.encoder)?;
|
||||
} else {
|
||||
video_encoder = enc_helper::get_best_compatible_encoder(
|
||||
&video_encoders,
|
||||
enc_helper::VideoCodec::from_str(&args.encoding.video.codec),
|
||||
enc_helper::EncoderType::from_str(&args.encoding.video.encoder_type),
|
||||
);
|
||||
&args.encoding.video.codec,
|
||||
&args.encoding.video.encoder_type,
|
||||
)?;
|
||||
}
|
||||
if video_encoder.is_none() {
|
||||
println!(
|
||||
"No video encoder found with the specified parameters: name='{}', vcodec='{}', type='{}'",
|
||||
args.encoding.video.encoder,
|
||||
args.encoding.video.codec,
|
||||
args.encoding.video.encoder_type
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let video_encoder = video_encoder.unwrap();
|
||||
println!("Selected video encoder: '{}'", video_encoder.name);
|
||||
Some(video_encoder)
|
||||
tracing::info!("Selected video encoder: '{}'", video_encoder.name);
|
||||
Ok(video_encoder)
|
||||
}
|
||||
|
||||
// Handles picking preferred settings for video encoder
|
||||
@@ -141,16 +133,16 @@ fn handle_encoder_video_settings(
|
||||
encoding_args::RateControl::VBR(vbr) => {
|
||||
optimized_encoder = enc_helper::encoder_vbr_params(
|
||||
&optimized_encoder,
|
||||
vbr.target_bitrate as u32,
|
||||
vbr.max_bitrate as u32,
|
||||
vbr.target_bitrate,
|
||||
vbr.max_bitrate,
|
||||
);
|
||||
}
|
||||
encoding_args::RateControl::CBR(cbr) => {
|
||||
optimized_encoder =
|
||||
enc_helper::encoder_cbr_params(&optimized_encoder, cbr.target_bitrate as u32);
|
||||
enc_helper::encoder_cbr_params(&optimized_encoder, cbr.target_bitrate);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
tracing::info!(
|
||||
"Selected video encoder settings: '{}'",
|
||||
optimized_encoder.get_parameters_string()
|
||||
);
|
||||
@@ -165,7 +157,7 @@ fn handle_encoder_audio(args: &args::Args) -> String {
|
||||
} else {
|
||||
args.encoding.audio.encoder.clone()
|
||||
};
|
||||
println!("Selected audio encoder: '{}'", audio_encoder);
|
||||
tracing::info!("Selected audio encoder: '{}'", audio_encoder);
|
||||
audio_encoder
|
||||
}
|
||||
|
||||
@@ -174,7 +166,14 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Parse command line arguments
|
||||
let mut args = args::Args::new();
|
||||
if args.app.verbose {
|
||||
// Make sure tracing has INFO level
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.init();
|
||||
|
||||
args.debug_print();
|
||||
} else {
|
||||
tracing_subscriber::fmt::init();
|
||||
}
|
||||
|
||||
rustls::crypto::ring::default_provider()
|
||||
@@ -192,33 +191,39 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
// Setup our websocket
|
||||
let nestri_ws = Arc::new(NestriWebSocket::new(ws_url).await?);
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
log::set_boxed_logger(Box::new(nestri_ws.clone())).unwrap();
|
||||
|
||||
gst::init()?;
|
||||
gstrswebrtc::plugin_register_static()?;
|
||||
|
||||
// Handle GPU selection
|
||||
let gpu = handle_gpus(&args);
|
||||
if gpu.is_none() {
|
||||
log::error!("Failed to find a suitable GPU. Exiting..");
|
||||
return Err("Failed to find a suitable GPU. Exiting..".into());
|
||||
}
|
||||
let gpu = gpu.unwrap();
|
||||
let gpu = match handle_gpus(&args) {
|
||||
Ok(gpu) => gpu,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to find a suitable GPU: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Currently DMA-BUF only works for NVIDIA
|
||||
if args.app.dma_buf && *gpu.vendor() != GPUVendor::NVIDIA {
|
||||
log::warn!("DMA-BUF is currently unsupported outside NVIDIA GPUs, force disabling..");
|
||||
args.app.dma_buf = false;
|
||||
if args.app.dma_buf {
|
||||
if args.encoding.video.encoder_type != EncoderType::HARDWARE {
|
||||
tracing::warn!("DMA-BUF is only supported with hardware encoders, disabling DMA-BUF..");
|
||||
args.app.dma_buf = false;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"DMA-BUF is experimental, it may or may not improve performance, or even work at all."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle video encoder selection
|
||||
let video_encoder_info = handle_encoder_video(&args);
|
||||
if video_encoder_info.is_none() {
|
||||
log::error!("Failed to find a suitable video encoder. Exiting..");
|
||||
return Err("Failed to find a suitable video encoder. Exiting..".into());
|
||||
}
|
||||
let mut video_encoder_info = video_encoder_info.unwrap();
|
||||
let mut video_encoder_info = match handle_encoder_video(&args) {
|
||||
Ok(encoder) => encoder,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to find a suitable video encoder: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle video encoder settings
|
||||
video_encoder_info = handle_encoder_video_settings(&args, &video_encoder_info);
|
||||
|
||||
@@ -232,10 +237,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
/* Audio */
|
||||
// Audio Source Element
|
||||
let audio_source = match args.encoding.audio.capture_method {
|
||||
encoding_args::AudioCaptureMethod::PulseAudio => {
|
||||
encoding_args::AudioCaptureMethod::PULSEAUDIO => {
|
||||
gst::ElementFactory::make("pulsesrc").build()?
|
||||
}
|
||||
encoding_args::AudioCaptureMethod::PipeWire => {
|
||||
encoding_args::AudioCaptureMethod::PIPEWIRE => {
|
||||
gst::ElementFactory::make("pipewiresrc").build()?
|
||||
}
|
||||
encoding_args::AudioCaptureMethod::ALSA => gst::ElementFactory::make("alsasrc").build()?,
|
||||
@@ -257,8 +262,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
audio_encoder.set_property(
|
||||
"bitrate",
|
||||
&match &args.encoding.audio.rate_control {
|
||||
encoding_args::RateControl::CBR(cbr) => cbr.target_bitrate * 1000i32,
|
||||
encoding_args::RateControl::VBR(vbr) => vbr.target_bitrate * 1000i32,
|
||||
encoding_args::RateControl::CBR(cbr) => cbr.target_bitrate.saturating_mul(1000) as i32,
|
||||
encoding_args::RateControl::VBR(vbr) => vbr.target_bitrate.saturating_mul(1000) as i32,
|
||||
_ => 128000i32,
|
||||
},
|
||||
);
|
||||
@@ -269,7 +274,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
/* Video */
|
||||
// Video Source Element
|
||||
let video_source = gst::ElementFactory::make("waylanddisplaysrc").build()?;
|
||||
let video_source = Arc::new(gst::ElementFactory::make("waylanddisplaysrc").build()?);
|
||||
video_source.set_property_from_str("render-node", gpu.render_path());
|
||||
|
||||
// Caps Filter Element (resolution, fps)
|
||||
@@ -288,7 +293,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
))?;
|
||||
caps_filter.set_property("caps", &caps);
|
||||
|
||||
// GL Upload Element
|
||||
// GL Upload element
|
||||
let glupload = gst::ElementFactory::make("glupload").build()?;
|
||||
|
||||
// GL color convert element
|
||||
@@ -299,6 +304,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let gl_caps = gst::Caps::from_str("video/x-raw(memory:GLMemory),format=NV12")?;
|
||||
gl_caps_filter.set_property("caps", &gl_caps);
|
||||
|
||||
// GL download element (needed only for DMA-BUF outside NVIDIA GPUs)
|
||||
let gl_download = gst::ElementFactory::make("gldownload").build()?;
|
||||
|
||||
// Video Converter Element
|
||||
let video_converter = gst::ElementFactory::make("videoconvert").build()?;
|
||||
|
||||
@@ -320,7 +328,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
/* Output */
|
||||
// WebRTC sink Element
|
||||
let signaller = NestriSignaller::new(nestri_ws.clone(), pipeline.clone());
|
||||
let signaller = NestriSignaller::new(nestri_ws.clone(), video_source.clone());
|
||||
let webrtcsink = BaseWebRTCSink::with_signaller(Signallable::from(signaller.clone()));
|
||||
webrtcsink.set_property_from_str("stun-server", "stun://stun.l.google.com:19302");
|
||||
webrtcsink.set_property_from_str("congestion-control", "disabled");
|
||||
@@ -372,7 +380,11 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
// If DMA-BUF is enabled, add glupload, color conversion and caps filter
|
||||
if args.app.dma_buf {
|
||||
pipeline.add_many(&[&glupload, &glcolorconvert, &gl_caps_filter])?;
|
||||
if *gpu.vendor() == GPUVendor::NVIDIA {
|
||||
pipeline.add_many(&[&glupload, &glcolorconvert, &gl_caps_filter])?;
|
||||
} else {
|
||||
pipeline.add_many(&[&glupload, &glcolorconvert, &gl_caps_filter, &gl_download])?;
|
||||
}
|
||||
}
|
||||
|
||||
// Link main audio branch
|
||||
@@ -389,19 +401,31 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
// With DMA-BUF, also link glupload and it's caps
|
||||
if args.app.dma_buf {
|
||||
// Link video source to caps_filter, glupload, gl_caps_filter, video_converter, video_encoder, webrtcsink
|
||||
gst::Element::link_many(&[
|
||||
&video_source,
|
||||
&caps_filter,
|
||||
&video_queue,
|
||||
&video_clocksync,
|
||||
&glupload,
|
||||
&glcolorconvert,
|
||||
&gl_caps_filter,
|
||||
&video_encoder,
|
||||
])?;
|
||||
if *gpu.vendor() == GPUVendor::NVIDIA {
|
||||
gst::Element::link_many(&[
|
||||
&video_source,
|
||||
&caps_filter,
|
||||
&video_queue,
|
||||
&video_clocksync,
|
||||
&glupload,
|
||||
&glcolorconvert,
|
||||
&gl_caps_filter,
|
||||
&video_encoder,
|
||||
])?;
|
||||
} else {
|
||||
gst::Element::link_many(&[
|
||||
&video_source,
|
||||
&caps_filter,
|
||||
&video_queue,
|
||||
&video_clocksync,
|
||||
&glupload,
|
||||
&glcolorconvert,
|
||||
&gl_caps_filter,
|
||||
&gl_download,
|
||||
&video_encoder,
|
||||
])?;
|
||||
}
|
||||
} else {
|
||||
// Link video source to caps_filter, video_converter, video_encoder, webrtcsink
|
||||
gst::Element::link_many(&[
|
||||
&video_source,
|
||||
&caps_filter,
|
||||
@@ -437,9 +461,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let result = run_pipeline(pipeline.clone()).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => log::info!("All tasks completed successfully"),
|
||||
Ok(_) => tracing::info!("All tasks finished"),
|
||||
Err(e) => {
|
||||
log::error!("Error occurred in one of the tasks: {}", e);
|
||||
tracing::error!("Error occurred in one of the tasks: {}", e);
|
||||
return Err("Error occurred in one of the tasks".into());
|
||||
}
|
||||
}
|
||||
@@ -452,7 +476,7 @@ async fn run_pipeline(pipeline: Arc<gst::Pipeline>) -> Result<(), Box<dyn Error>
|
||||
|
||||
{
|
||||
if let Err(e) = pipeline.set_state(gst::State::Playing) {
|
||||
log::error!("Failed to start pipeline: {}", e);
|
||||
tracing::error!("Failed to start pipeline: {}", e);
|
||||
return Err("Failed to start pipeline".into());
|
||||
}
|
||||
}
|
||||
@@ -460,12 +484,12 @@ async fn run_pipeline(pipeline: Arc<gst::Pipeline>) -> Result<(), Box<dyn Error>
|
||||
// Wait for EOS or error (don't lock the pipeline indefinitely)
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
log::info!("Pipeline interrupted via Ctrl+C");
|
||||
tracing::info!("Pipeline interrupted via Ctrl+C");
|
||||
}
|
||||
result = listen_for_gst_messages(bus) => {
|
||||
match result {
|
||||
Ok(_) => log::info!("Pipeline finished with EOS"),
|
||||
Err(err) => log::error!("Pipeline error: {}", err),
|
||||
Ok(_) => tracing::info!("Pipeline finished with EOS"),
|
||||
Err(err) => tracing::error!("Pipeline error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,7 +509,7 @@ async fn listen_for_gst_messages(bus: gst::Bus) -> Result<(), Box<dyn Error>> {
|
||||
while let Some(msg) = bus_stream.next().await {
|
||||
match msg.view() {
|
||||
gst::MessageView::Eos(_) => {
|
||||
log::info!("Received EOS");
|
||||
tracing::info!("Received EOS");
|
||||
break;
|
||||
}
|
||||
gst::MessageView::Error(err) => {
|
||||
|
||||
@@ -107,7 +107,6 @@ pub fn encode_message<T: Serialize>(message: &T) -> Result<String, Box<dyn Error
|
||||
}
|
||||
|
||||
pub fn decode_message(data: String) -> Result<MessageBase, Box<dyn Error + Send + Sync>> {
|
||||
println!("Data: {}", data);
|
||||
let base_message: MessageBase = serde_json::from_str(&data)?;
|
||||
Ok(base_message)
|
||||
}
|
||||
|
||||
@@ -21,14 +21,14 @@ use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
|
||||
|
||||
pub struct Signaller {
|
||||
nestri_ws: PLRwLock<Option<Arc<NestriWebSocket>>>,
|
||||
pipeline: PLRwLock<Option<Arc<gst::Pipeline>>>,
|
||||
wayland_src: PLRwLock<Option<Arc<gst::Element>>>,
|
||||
data_channel: AtomicRefCell<Option<gst_webrtc::WebRTCDataChannel>>,
|
||||
}
|
||||
impl Default for Signaller {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nestri_ws: PLRwLock::new(None),
|
||||
pipeline: PLRwLock::new(None),
|
||||
wayland_src: PLRwLock::new(None),
|
||||
data_channel: AtomicRefCell::new(None),
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,12 @@ impl Signaller {
|
||||
*self.nestri_ws.write() = Some(nestri_ws);
|
||||
}
|
||||
|
||||
pub fn set_pipeline(&self, pipeline: Arc<gst::Pipeline>) {
|
||||
*self.pipeline.write() = Some(pipeline);
|
||||
pub fn set_wayland_src(&self, wayland_src: Arc<gst::Element>) {
|
||||
*self.wayland_src.write() = Some(wayland_src);
|
||||
}
|
||||
|
||||
pub fn get_pipeline(&self) -> Option<Arc<gst::Pipeline>> {
|
||||
self.pipeline.read().clone()
|
||||
pub fn get_wayland_src(&self) -> Option<Arc<gst::Element>> {
|
||||
self.wayland_src.read().clone()
|
||||
}
|
||||
|
||||
pub fn set_data_channel(&self, data_channel: gst_webrtc::WebRTCDataChannel) {
|
||||
@@ -159,8 +159,8 @@ impl Signaller {
|
||||
);
|
||||
if let Some(data_channel) = data_channel {
|
||||
gst::info!(gst::CAT_DEFAULT, "Data channel created");
|
||||
if let Some(pipeline) = signaller.imp().get_pipeline() {
|
||||
setup_data_channel(&data_channel, &pipeline);
|
||||
if let Some(wayland_src) = signaller.imp().get_wayland_src() {
|
||||
setup_data_channel(&data_channel, &*wayland_src);
|
||||
signaller.imp().set_data_channel(data_channel);
|
||||
} else {
|
||||
gst::error!(gst::CAT_DEFAULT, "Wayland display source not set");
|
||||
@@ -201,7 +201,7 @@ impl SignallableImpl for Signaller {
|
||||
// Wait for a reconnection notification
|
||||
reconnected_notify.notified().await;
|
||||
|
||||
println!("Reconnected to relay, re-negotiating...");
|
||||
tracing::warn!("Reconnected to relay, re-negotiating...");
|
||||
gst::warning!(gst::CAT_DEFAULT, "Reconnected to relay, re-negotiating...");
|
||||
|
||||
// Emit "session-ended" first to make sure the element is cleaned up
|
||||
@@ -255,7 +255,7 @@ impl SignallableImpl for Signaller {
|
||||
};
|
||||
if let Ok(encoded) = encode_message(&join_msg) {
|
||||
if let Err(e) = nestri_ws.send_message(encoded) {
|
||||
eprintln!("Failed to send join message: {:?}", e);
|
||||
tracing::error!("Failed to send join message: {:?}", e);
|
||||
gst::error!(gst::CAT_DEFAULT, "Failed to send join message: {:?}", e);
|
||||
}
|
||||
} else {
|
||||
@@ -283,7 +283,7 @@ impl SignallableImpl for Signaller {
|
||||
};
|
||||
if let Ok(encoded) = encode_message(&sdp_message) {
|
||||
if let Err(e) = nestri_ws.send_message(encoded) {
|
||||
eprintln!("Failed to send SDP message: {:?}", e);
|
||||
tracing::error!("Failed to send SDP message: {:?}", e);
|
||||
gst::error!(gst::CAT_DEFAULT, "Failed to send SDP message: {:?}", e);
|
||||
}
|
||||
} else {
|
||||
@@ -319,7 +319,7 @@ impl SignallableImpl for Signaller {
|
||||
};
|
||||
if let Ok(encoded) = encode_message(&ice_message) {
|
||||
if let Err(e) = nestri_ws.send_message(encoded) {
|
||||
eprintln!("Failed to send ICE message: {:?}", e);
|
||||
tracing::error!("Failed to send ICE message: {:?}", e);
|
||||
gst::error!(gst::CAT_DEFAULT, "Failed to send ICE message: {:?}", e);
|
||||
}
|
||||
} else {
|
||||
@@ -361,8 +361,8 @@ impl ObjectImpl for Signaller {
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_data_channel(data_channel: &gst_webrtc::WebRTCDataChannel, pipeline: &gst::Pipeline) {
|
||||
let pipeline = pipeline.clone();
|
||||
fn setup_data_channel(data_channel: &gst_webrtc::WebRTCDataChannel, wayland_src: &gst::Element) {
|
||||
let wayland_src = wayland_src.clone();
|
||||
|
||||
data_channel.connect_on_message_data(move |_data_channel, data| {
|
||||
if let Some(data) = data {
|
||||
@@ -371,15 +371,15 @@ fn setup_data_channel(data_channel: &gst_webrtc::WebRTCDataChannel, pipeline: &g
|
||||
if let Some(input_msg) = message_input.data {
|
||||
// Process the input message and create an event
|
||||
if let Some(event) = handle_input_message(input_msg) {
|
||||
// Send the event to pipeline, result bool is ignored
|
||||
let _ = pipeline.send_event(event);
|
||||
// Send the event to wayland source, result bool is ignored
|
||||
let _ = wayland_src.send_event(event);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Failed to parse InputMessage");
|
||||
tracing::error!("Failed to parse InputMessage");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to decode MessageInput: {:?}", e);
|
||||
tracing::error!("Failed to decode MessageInput: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ glib::wrapper! {
|
||||
}
|
||||
|
||||
impl NestriSignaller {
|
||||
pub fn new(nestri_ws: Arc<NestriWebSocket>, pipeline: Arc<gst::Pipeline>) -> Self {
|
||||
pub fn new(nestri_ws: Arc<NestriWebSocket>, wayland_src: Arc<gst::Element>) -> Self {
|
||||
let obj: Self = glib::Object::new();
|
||||
obj.imp().set_nestri_ws(nestri_ws);
|
||||
obj.imp().set_pipeline(pipeline);
|
||||
obj.imp().set_wayland_src(wayland_src);
|
||||
obj
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::messages::{MessageBase, MessageLog, decode_message, encode_message};
|
||||
use crate::messages::decode_message;
|
||||
use futures_util::StreamExt;
|
||||
use futures_util::sink::SinkExt;
|
||||
use futures_util::stream::{SplitSink, SplitStream};
|
||||
use log::{Level, Log, Metadata, Record};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::sync::{Arc, RwLock};
|
||||
@@ -63,7 +62,7 @@ impl NestriWebSocket {
|
||||
return Ok(ws_stream);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to connect to WebSocket, retrying: {:?}", e);
|
||||
tracing::error!("Failed to connect to WebSocket, retrying: {:?}", e);
|
||||
sleep(Duration::from_secs(3)).await; // Wait before retrying
|
||||
}
|
||||
}
|
||||
@@ -87,7 +86,7 @@ impl NestriWebSocket {
|
||||
let mut ws_read = match ws_read_option {
|
||||
Some(ws_read) => ws_read,
|
||||
None => {
|
||||
eprintln!("Reader is None, cannot proceed");
|
||||
tracing::error!("Reader is None, cannot proceed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -101,7 +100,7 @@ impl NestriWebSocket {
|
||||
let base_message = match decode_message(data.to_string()) {
|
||||
Ok(base_message) => base_message,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to decode message: {:?}", e);
|
||||
tracing::error!("Failed to decode message: {:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -113,7 +112,7 @@ impl NestriWebSocket {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
tracing::error!(
|
||||
"Error receiving message: {:?}, reconnecting in 3 seconds...",
|
||||
e
|
||||
);
|
||||
@@ -150,10 +149,10 @@ impl NestriWebSocket {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error sending message: {:?}", e);
|
||||
tracing::error!("Error sending message: {:?}", e);
|
||||
// Attempt to reconnect
|
||||
if let Err(e) = self_clone.reconnect().await {
|
||||
eprintln!("Error during reconnection: {:?}", e);
|
||||
tracing::error!("Error during reconnection: {:?}", e);
|
||||
// Wait before retrying
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
continue;
|
||||
@@ -161,10 +160,10 @@ impl NestriWebSocket {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("Writer is None, cannot send message");
|
||||
tracing::error!("Writer is None, cannot send message");
|
||||
// Attempt to reconnect
|
||||
if let Err(e) = self_clone.reconnect().await {
|
||||
eprintln!("Error during reconnection: {:?}", e);
|
||||
tracing::error!("Error during reconnection: {:?}", e);
|
||||
// Wait before retrying
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
continue;
|
||||
@@ -196,7 +195,7 @@ impl NestriWebSocket {
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to reconnect to WebSocket: {:?}", e);
|
||||
tracing::error!("Failed to reconnect to WebSocket: {:?}", e);
|
||||
sleep(Duration::from_secs(3)).await; // Wait before retrying
|
||||
}
|
||||
}
|
||||
@@ -224,39 +223,3 @@ impl NestriWebSocket {
|
||||
self.reconnected_notify.clone()
|
||||
}
|
||||
}
|
||||
impl Log for NestriWebSocket {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= Level::Info
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if self.enabled(record.metadata()) {
|
||||
let level = record.level().to_string();
|
||||
let message = record.args().to_string();
|
||||
let time = chrono::Local::now().to_rfc3339();
|
||||
|
||||
// Print to console as well
|
||||
println!("{}: {}", level, message);
|
||||
|
||||
// Encode and send the log message
|
||||
let log_message = MessageLog {
|
||||
base: MessageBase {
|
||||
payload_type: "log".to_string(),
|
||||
latency: None,
|
||||
},
|
||||
level,
|
||||
message,
|
||||
time,
|
||||
};
|
||||
if let Ok(encoded_message) = encode_message(&log_message) {
|
||||
if let Err(e) = self.send_message(encoded_message) {
|
||||
eprintln!("Failed to send log message: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
// No-op for this logger
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@nestri/www",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
@@ -30,12 +31,13 @@
|
||||
"@nestri/zero": "*",
|
||||
"@openauthjs/openauth": "*",
|
||||
"@openauthjs/solid": "0.0.0-20250311201457",
|
||||
"@rocicorp/zero": "*",
|
||||
"@rocicorp/zero": "0.20.2025051800",
|
||||
"@solid-primitives/event-listener": "^2.4.0",
|
||||
"@solid-primitives/storage": "^4.3.1",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"body-scroll-lock-upgrade": "^1.1.0",
|
||||
"eventsource": "^3.0.5",
|
||||
"fast-average-color": "9.5.0",
|
||||
"focus-trap": "^7.6.4",
|
||||
"hono": "^4.7.4",
|
||||
"modern-normalize": "^3.0.1",
|
||||
|
||||
@@ -8,14 +8,16 @@ import '@fontsource/geist-sans/800.css';
|
||||
import '@fontsource/geist-sans/900.css';
|
||||
import { Text } from '@nestri/www/ui/text';
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { Screen as FullScreen } from '@nestri/www/ui/layout';
|
||||
import { TeamRoute } from '@nestri/www/pages/team';
|
||||
import { ZeroProvider } from './providers/zero';
|
||||
import { ProfilesRoute } from './pages/profiles';
|
||||
import { NewProfile } from '@nestri/www/pages/new';
|
||||
import { SteamRoute } from '@nestri/www/pages/steam';
|
||||
import { OpenAuthProvider } from "@openauthjs/solid";
|
||||
import { NotFound } from '@nestri/www/pages/not-found';
|
||||
import { Navigate, Route, Router } from "@solidjs/router";
|
||||
import { globalStyle, macaron$ } from "@macaron-css/core";
|
||||
import { useStorage } from '@nestri/www/providers/account';
|
||||
import { CreateTeamComponent } from '@nestri/www/pages/new';
|
||||
import { Screen as FullScreen } from '@nestri/www/ui/layout';
|
||||
import { darkClass, lightClass, theme } from '@nestri/www/ui/theme';
|
||||
import { AccountProvider, useAccount } from '@nestri/www/providers/account';
|
||||
import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js';
|
||||
@@ -92,43 +94,44 @@ export const App: Component = () => {
|
||||
const storage = useStorage();
|
||||
|
||||
return (
|
||||
// <OpenAuthProvider
|
||||
// issuer={import.meta.env.VITE_AUTH_URL}
|
||||
// clientID="web"
|
||||
// >
|
||||
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
|
||||
<OpenAuthProvider
|
||||
issuer={import.meta.env.VITE_AUTH_URL}
|
||||
clientID="web"
|
||||
>
|
||||
<Root class={theme() === "light" ? lightClass : darkClass}>
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
component={(props) => (
|
||||
// <AccountProvider
|
||||
// loadingUI={
|
||||
// <FullScreen>
|
||||
// <Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity…</Text>
|
||||
// </FullScreen>
|
||||
// }>
|
||||
// {props.children}
|
||||
props.children
|
||||
// </AccountProvider>
|
||||
<AccountProvider
|
||||
loadingUI={
|
||||
<FullScreen>
|
||||
<Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity…</Text>
|
||||
</FullScreen>
|
||||
}>
|
||||
<ZeroProvider>
|
||||
{/* props.children */}
|
||||
{props.children}
|
||||
</ZeroProvider>
|
||||
</AccountProvider>
|
||||
)}
|
||||
>
|
||||
<Route path=":teamSlug">{TeamRoute}</Route>
|
||||
<Route path="new" component={CreateTeamComponent} />
|
||||
<Route path=":steamID">{SteamRoute}</Route>
|
||||
<Route path="profiles" component={ProfilesRoute} />
|
||||
<Route path="new" component={NewProfile} />
|
||||
<Route
|
||||
path="/"
|
||||
component={() => {
|
||||
const account = useAccount();
|
||||
return (
|
||||
<Switch>
|
||||
{/**FIXME: Somehow this does not work when the user is in the "/new" page */}
|
||||
<Match when={account.current.teams.length > 0}>
|
||||
<Match when={account.current.profiles.length > 0}>
|
||||
<Navigate
|
||||
href={`/${(
|
||||
account.current.teams.find(
|
||||
(w) => w.id === storage.value.team,
|
||||
) || account.current.teams[0]
|
||||
).slug
|
||||
}`}
|
||||
account.current.profiles.find(
|
||||
(w) => w.id === storage.value.steam,
|
||||
) || account.current.profiles[0]
|
||||
).id}`}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -142,6 +145,6 @@ export const App: Component = () => {
|
||||
</Route>
|
||||
</Router>
|
||||
</Root>
|
||||
// </OpenAuthProvider>
|
||||
</OpenAuthProvider>
|
||||
)
|
||||
}
|
||||
35
packages/www/src/components/profile-picture.tsx
Normal file
35
packages/www/src/components/profile-picture.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createSignal, type JSX, onMount } from "solid-js";
|
||||
|
||||
type SteamAvatarProps = {
|
||||
avatarHash: string;
|
||||
alt?: string;
|
||||
class?: string;
|
||||
style?: string | JSX.CSSProperties;
|
||||
};
|
||||
|
||||
export default function SteamAvatar(props: SteamAvatarProps) {
|
||||
const smallUrl = `https://avatars.cloudflare.steamstatic.com/${props.avatarHash}.jpg`;
|
||||
const fullUrl = `https://avatars.cloudflare.steamstatic.com/${props.avatarHash}_full.jpg`;
|
||||
|
||||
const [src, setSrc] = createSignal(smallUrl);
|
||||
|
||||
onMount(() => {
|
||||
const img = new Image();
|
||||
img.src = fullUrl;
|
||||
img.onload = () => setSrc(fullUrl);
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src()}
|
||||
alt={props.alt ?? "Steam Avatar"}
|
||||
class={props.class}
|
||||
style={{
|
||||
"height": "100%",
|
||||
"width": "100%",
|
||||
"object-fit": "cover",
|
||||
...typeof props.style === "string" ? {} : props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
import { EventSource } from 'eventsource'
|
||||
import { QRCode } from "../ui/custom-qr";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { keyframes } from "@macaron-css/core";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { useAccount } from "../providers/account";
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import { Container, Screen as FullScreen } from "@nestri/www/ui/layout";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
|
||||
const Card = styled("div", {
|
||||
base: {
|
||||
padding: `10px 20px`,
|
||||
maxWidth: 360,
|
||||
gap: 40,
|
||||
maxWidth: 400,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
gap: 20,
|
||||
padding: `10px 20px`,
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}
|
||||
@@ -48,33 +43,45 @@ const Logo = styled("svg", {
|
||||
}
|
||||
})
|
||||
|
||||
const Title = styled("h2", {
|
||||
const Title = styled("h1", {
|
||||
base: {
|
||||
fontSize: theme.font.size["2xl"],
|
||||
lineHeight: "2rem",
|
||||
textWrap: "balance",
|
||||
letterSpacing: "-0.029375rem",
|
||||
fontSize: theme.font.size["4xl"],
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
fontFamily: theme.font.family.heading
|
||||
}
|
||||
})
|
||||
|
||||
const Subtitle = styled("h2", {
|
||||
base: {
|
||||
fontSize: theme.font.size["base"],
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
}
|
||||
})
|
||||
|
||||
const Button = styled("button", {
|
||||
base: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "not-allowed",
|
||||
padding: "10px 20px",
|
||||
gap: theme.space["2"],
|
||||
cursor: "pointer",
|
||||
padding: "0px 14px",
|
||||
gap: 10,
|
||||
height: 48,
|
||||
borderRadius: theme.space["2"],
|
||||
backgroundColor: theme.color.background.d100,
|
||||
border: `1px solid ${theme.color.gray.d400}`
|
||||
},
|
||||
variants: {
|
||||
comingSoon: {
|
||||
true: {
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 20px",
|
||||
gap: theme.space["2"],
|
||||
cursor: "not-allowed",
|
||||
}
|
||||
},
|
||||
steamBtn: {
|
||||
true: {
|
||||
color: "#FFF",
|
||||
backgroundColor: "#2D73FF"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -86,7 +93,7 @@ const ButtonText = styled("span", {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const ButtonIcon = styled("svg", {
|
||||
@@ -105,230 +112,6 @@ const ButtonContainer = styled("div", {
|
||||
}
|
||||
})
|
||||
|
||||
const bgRotate = keyframes({
|
||||
'to': { transform: 'rotate(1turn)' },
|
||||
});
|
||||
|
||||
const shake = keyframes({
|
||||
"0%": {
|
||||
transform: "translateX(0)",
|
||||
},
|
||||
"50%": {
|
||||
transform: "translateX(10px)",
|
||||
},
|
||||
"100%": {
|
||||
transform: "translateX(0)",
|
||||
},
|
||||
});
|
||||
|
||||
const opacity = keyframes({
|
||||
"0%": { opacity: 1 },
|
||||
"100%": { opacity: 0 }
|
||||
})
|
||||
|
||||
const QRContainer = styled("div", {
|
||||
base: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: 30,
|
||||
padding: 9,
|
||||
isolation: "isolate",
|
||||
":after": {
|
||||
content: "",
|
||||
zIndex: -1,
|
||||
inset: 10,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
borderRadius: 30,
|
||||
position: "absolute"
|
||||
}
|
||||
},
|
||||
variants: {
|
||||
login: {
|
||||
true: {
|
||||
":before": {
|
||||
content: "",
|
||||
backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.blue.d700} 10%,${theme.color.blue.d700} 25%,transparent 35%)`,
|
||||
animation: `${bgRotate} 2.25s linear infinite`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
zIndex: -2,
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
position: "absolute"
|
||||
},
|
||||
}
|
||||
},
|
||||
error: {
|
||||
true: {
|
||||
animation: `${shake} 100ms ease 3`,
|
||||
":before": {
|
||||
content: "",
|
||||
inset: 1,
|
||||
background: theme.color.red.d700,
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
animation: `${opacity} 3s ease`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
}
|
||||
}
|
||||
},
|
||||
success: {
|
||||
true: {
|
||||
animation: `${shake} 100ms ease 3`,
|
||||
// ":before": {
|
||||
// content: "",
|
||||
// backgroundImage: `conic-gradient(from 0deg,transparent 0,${theme.color.green.d700} 10%,${theme.color.green.d700} 25%,transparent 35%)`,
|
||||
// animation: `${bgRotate} 2.25s linear infinite`,
|
||||
// width: "200%",
|
||||
// height: "200%",
|
||||
// zIndex: -2,
|
||||
// top: "-50%",
|
||||
// left: "-50%",
|
||||
// position: "absolute"
|
||||
// },
|
||||
":before": {
|
||||
content: "",
|
||||
inset: 1,
|
||||
background: theme.color.teal.d700,
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
animation: `${opacity} 1.1s ease infinite`,
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const QRBg = styled("div", {
|
||||
base: {
|
||||
backgroundColor: theme.color.background.d200,
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
margin: 5,
|
||||
borderRadius: 27
|
||||
}
|
||||
})
|
||||
|
||||
const QRWrapper = styled("div", {
|
||||
base: {
|
||||
height: "max-content",
|
||||
width: "max-content",
|
||||
backgroundColor: theme.color.d1000.gray,
|
||||
position: "relative",
|
||||
textWrap: "balance",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
borderRadius: 22,
|
||||
padding: 20,
|
||||
},
|
||||
variants: {
|
||||
error: {
|
||||
true: {
|
||||
filter: "blur(3px)",
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRReloadBtn = styled("button", {
|
||||
base: {
|
||||
background: "none",
|
||||
border: "none",
|
||||
width: 50,
|
||||
height: 50,
|
||||
position: "absolute",
|
||||
borderRadius: 25,
|
||||
zIndex: 5,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
cursor: "pointer",
|
||||
color: theme.color.blue.d700,
|
||||
transition: "color 200ms",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
":before": {
|
||||
zIndex: 3,
|
||||
content: "",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
transition: "opacity 200ms",
|
||||
background: "#FFF"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRRealoadContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
isolation: "isolate",
|
||||
":before": {
|
||||
background: `conic-gradient( from 90deg, currentColor 10%, #FFF 80% )`,
|
||||
inset: 3,
|
||||
borderRadius: 16,
|
||||
position: "absolute",
|
||||
content: "",
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const QRReloadSvg = styled("svg", {
|
||||
base: {
|
||||
zIndex: 2,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
display: "block"
|
||||
}
|
||||
})
|
||||
|
||||
const LogoContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
color: theme.color.gray.d100
|
||||
}
|
||||
})
|
||||
|
||||
const LogoIcon = styled("svg", {
|
||||
base: {
|
||||
zIndex: 6,
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
overflow: "hidden",
|
||||
borderRadius: 17,
|
||||
}
|
||||
})
|
||||
|
||||
const Divider = styled("hr", {
|
||||
base: {
|
||||
height: "100%",
|
||||
backgroundColor: theme.color.gray.d400,
|
||||
width: 2,
|
||||
border: "none",
|
||||
margin: "0 20px",
|
||||
padding: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const CardWrapper = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
@@ -338,7 +121,7 @@ const CardWrapper = styled("div", {
|
||||
display: "flex",
|
||||
alignItems: "start",
|
||||
justifyContent: "start",
|
||||
top: "25vh"
|
||||
top: "16vh"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -374,97 +157,109 @@ const Link = styled("a", {
|
||||
}
|
||||
})
|
||||
|
||||
export function CreateTeamComponent() {
|
||||
const Divider = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "center",
|
||||
":before": {
|
||||
width: "100%",
|
||||
content: "",
|
||||
borderTop: `1px solid ${theme.color.gray.d500}`,
|
||||
alignSelf: "center"
|
||||
},
|
||||
":after": {
|
||||
width: "100%",
|
||||
content: "",
|
||||
borderTop: `1px solid ${theme.color.gray.d500}`,
|
||||
alignSelf: "center"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const DividerText = styled("span", {
|
||||
base: {
|
||||
margin: "0px 10px",
|
||||
fontSize: theme.font.size["xs"],
|
||||
color: theme.color.gray.d900,
|
||||
lineHeight: "20px",
|
||||
textOverflow: "ellipsis"
|
||||
}
|
||||
})
|
||||
|
||||
export function NewProfile() {
|
||||
const nav = useNavigate();
|
||||
const auth = useOpenAuth();
|
||||
const account = useAccount();
|
||||
|
||||
const [challengeUrl, setChallengeUrl] = createSignal<string | null>(null);
|
||||
const [timedOut, setTimedOut] = createSignal(false);
|
||||
const [errorMsg, setErrorMsg] = createSignal<string | null>("");
|
||||
const [loginSuccess, setLoginSuccess] = createSignal(false);
|
||||
const openPopup = () => {
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// bump this to reconnect
|
||||
const [retryCount, setRetryCount] = createSignal(0);
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
let currentStream: EventSource | null = null;
|
||||
const createDesktopWindow = (authUrl: string) => {
|
||||
const config = {
|
||||
width: 700,
|
||||
height: 700,
|
||||
features: "toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=no,resizable=no,copyhistory=no"
|
||||
};
|
||||
|
||||
const connectStream = async () => {
|
||||
// clear previous state
|
||||
setChallengeUrl(null);
|
||||
setTimedOut(false);
|
||||
setErrorMsg(null);
|
||||
const top = window.top!.outerHeight / 2 + window.top!.screenY - (config.height / 2);
|
||||
const left = window.top!.outerWidth / 2 + window.top!.screenX - (config.width / 2);
|
||||
|
||||
if (currentStream) {
|
||||
currentStream.close();
|
||||
return window.open(
|
||||
authUrl,
|
||||
'Steam Popup',
|
||||
`width=${config.width},height=${config.height},left=${left},top=${top},${config.features}`
|
||||
);
|
||||
};
|
||||
|
||||
const monitorAuthWindow = (
|
||||
targetWindow: Window,
|
||||
{ timeoutMs = 3 * 60 * 1000, pollInterval = 250 } = {}
|
||||
) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(async() => {
|
||||
await cleanup();
|
||||
reject(new Error("Authentication timed out"));
|
||||
}, timeoutMs);
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
if (targetWindow.closed) {
|
||||
await cleanup();
|
||||
resolve(); // Auth window closed by user
|
||||
}
|
||||
}, pollInterval);
|
||||
|
||||
async function cleanup() {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(poll);
|
||||
if (!targetWindow.closed) {
|
||||
try {
|
||||
targetWindow.location.href = "about:blank";
|
||||
targetWindow.close();
|
||||
} catch {
|
||||
// Ignore cross-origin issues
|
||||
}
|
||||
}
|
||||
await account.refresh(account.current.id)
|
||||
nav("/profiles")
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const authUrl = `${BASE_URL}/steam/popup/${account.current.id}`;
|
||||
const newWindow = isMobile ? window.open(authUrl, '_blank') : createDesktopWindow(authUrl);
|
||||
|
||||
if (!newWindow) {
|
||||
throw new Error('Failed to open authentication window');
|
||||
}
|
||||
|
||||
const token = await auth.access();
|
||||
const stream = new EventSource(
|
||||
`${import.meta.env.VITE_API_URL}/steam/login`,
|
||||
{
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
currentStream = stream;
|
||||
|
||||
// status
|
||||
// stream.addEventListener("status", (e) => {
|
||||
// // setStatus(JSON.parse(e.data).message);
|
||||
// });
|
||||
|
||||
// challenge URL
|
||||
stream.addEventListener("challenge_url", (e) => {
|
||||
setChallengeUrl(JSON.parse(e.data).url);
|
||||
});
|
||||
|
||||
// success
|
||||
stream.addEventListener("login_success", (e) => {
|
||||
setLoginSuccess(true);
|
||||
});
|
||||
|
||||
// timed out
|
||||
stream.addEventListener("timed_out", (e) => {
|
||||
setTimedOut(true);
|
||||
});
|
||||
|
||||
// server-side error
|
||||
stream.addEventListener("error", (e: any) => {
|
||||
// Network‐level errors also fire here
|
||||
try {
|
||||
const err = JSON.parse(e.data).message
|
||||
setErrorMsg(err);
|
||||
} catch {
|
||||
setErrorMsg("Connection error");
|
||||
}
|
||||
//Event source has inbuilt retry method,this is to prevent it from firing
|
||||
stream.close()
|
||||
});
|
||||
|
||||
// team slug
|
||||
stream.addEventListener("team_slug", async (e) => {
|
||||
await account.refresh(account.current.id)
|
||||
{/**FIXME: Somehow this does not work when the user is in the "/new" page */ }
|
||||
nav(`/${JSON.parse(e.data).username}`)
|
||||
});
|
||||
};
|
||||
|
||||
// kick it off on mount _and_ whenever retryCount changes
|
||||
createEffect(() => {
|
||||
// read retryCount so effect re-runs
|
||||
retryCount();
|
||||
connectStream();
|
||||
// ensure cleanup if component unmounts
|
||||
onCleanup(() => currentStream?.close());
|
||||
});
|
||||
return monitorAuthWindow(newWindow);
|
||||
}
|
||||
|
||||
return (
|
||||
<FullScreen>
|
||||
@@ -474,9 +269,28 @@ export function CreateTeamComponent() {
|
||||
style={{ position: "fixed", height: "100%" }} >
|
||||
<CardWrapper>
|
||||
<Card >
|
||||
<Title>Connect your game library to get started.</Title>
|
||||
<Title>Connect your game library to get started</Title>
|
||||
<ButtonContainer>
|
||||
<Button>
|
||||
<Button onClick={openPopup} steamBtn>
|
||||
<ButtonIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={32}
|
||||
height={32}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" />
|
||||
<path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</ButtonIcon>
|
||||
<ButtonText>
|
||||
Continue with Steam
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Divider>
|
||||
<DividerText>Or link</DividerText>
|
||||
</Divider>
|
||||
<Button comingSoon>
|
||||
<ButtonText>
|
||||
GOG.com
|
||||
<Soon>Soon</Soon>
|
||||
@@ -485,7 +299,7 @@ export function CreateTeamComponent() {
|
||||
<path fill="currentColor" d="M31,31H3a3,3,0,0,1-3-3V3A3,3,0,0,1,3,0H31a3,3,0,0,1,3,3V28A3,3,0,0,1,31,31ZM4,24.5A1.5,1.5,0,0,0,5.5,26H11V24H6.5a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5H11V18H5.5A1.5,1.5,0,0,0,4,19.5Zm8-18A1.5,1.5,0,0,0,10.5,5h-5A1.5,1.5,0,0,0,4,6.5v5A1.5,1.5,0,0,0,5.5,13H9V11H6.5a.5.5,0,0,1-.5-.5v-3A.5.5,0,0,1,6.5,7h3a.5.5,0,0,1,.5.5v6a.5.5,0,0,1-.5.5H4v2h6.5A1.5,1.5,0,0,0,12,14.5Zm0,13v5A1.5,1.5,0,0,0,13.5,26h5A1.5,1.5,0,0,0,20,24.5v-5A1.5,1.5,0,0,0,18.5,18h-5A1.5,1.5,0,0,0,12,19.5Zm9-13A1.5,1.5,0,0,0,19.5,5h-5A1.5,1.5,0,0,0,13,6.5v5A1.5,1.5,0,0,0,14.5,13h5A1.5,1.5,0,0,0,21,11.5Zm9,0A1.5,1.5,0,0,0,28.5,5h-5A1.5,1.5,0,0,0,22,6.5v5A1.5,1.5,0,0,0,23.5,13H27V11H24.5a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5h3a.5.5,0,0,1,.5.5v6a.5.5,0,0,1-.5.5H22v2h6.5A1.5,1.5,0,0,0,30,14.5ZM30,18H22.5A1.5,1.5,0,0,0,21,19.5V26h2V20.5a.5.5,0,0,1,.5-.5h1v6h2V20H28v6h2ZM18.5,11h-3a.5.5,0,0,1-.5-.5v-3a.5.5,0,0,1,.5-.5h3a.5.5,0,0,1,.5.5v3A.5.5,0,0,1,18.5,11Zm-4,9h3a.5.5,0,0,1,.5.5v3a.5.5,0,0,1-.5.5h-3a.5.5,0,0,1-.5-.5v-3A.5.5,0,0,1,14.5,20Z" />
|
||||
</ButtonIcon>
|
||||
</Button>
|
||||
<Button>
|
||||
<Button comingSoon>
|
||||
<ButtonText>
|
||||
Epic Games
|
||||
<Soon>Soon</Soon>
|
||||
@@ -494,7 +308,7 @@ export function CreateTeamComponent() {
|
||||
<path fill="currentColor" d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4 4 0 0 0 .02.433c.031.3.037.59.316.92c.027.033.311.245.311.245c.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2c0 0 .284-.211.311-.243c.28-.33.285-.621.316-.92a4 4 0 0 0 .02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.7 1.7 0 0 1 .591.108a1.8 1.8 0 0 1 .49.299l-.452.546a1.3 1.3 0 0 0-.308-.195a.9.9 0 0 0-.363-.068a.7.7 0 0 0-.28.06a.7.7 0 0 0-.224.163a.8.8 0 0 0-.151.243a.8.8 0 0 0-.056.299v.008a.9.9 0 0 0 .056.31a.7.7 0 0 0 .157.245a.7.7 0 0 0 .238.16a.8.8 0 0 0 .303.058a.8.8 0 0 0 .445-.116v-.339h-.548v-.565H7.37v1.255a2 2 0 0 1-.524.307a1.8 1.8 0 0 1-.683.123a1.6 1.6 0 0 1-.602-.107a1.5 1.5 0 0 1-.478-.3a1.4 1.4 0 0 1-.318-.455a1.4 1.4 0 0 1-.115-.58v-.008a1.4 1.4 0 0 1 .113-.57a1.5 1.5 0 0 1 .312-.46a1.4 1.4 0 0 1 .474-.309a1.6 1.6 0 0 1 .598-.111h.045zm11.963.008a2 2 0 0 1 .612.094a1.6 1.6 0 0 1 .507.277l-.386.546a1.6 1.6 0 0 0-.39-.205a1.2 1.2 0 0 0-.388-.07a.35.35 0 0 0-.208.052a.15.15 0 0 0-.07.127v.008a.16.16 0 0 0 .022.084a.2.2 0 0 0 .076.066a1 1 0 0 0 .147.06q.093.03.236.061a3 3 0 0 1 .43.122a1.3 1.3 0 0 1 .328.17a.7.7 0 0 1 .207.24a.74.74 0 0 1 .071.337v.008a.9.9 0 0 1-.081.382a.8.8 0 0 1-.229.285a1 1 0 0 1-.353.18a1.6 1.6 0 0 1-.46.061a2.2 2.2 0 0 1-.71-.116a1.7 1.7 0 0 1-.593-.346l.43-.514q.416.335.9.335a.46.46 0 0 0 .236-.05a.16.16 0 0 0 .082-.142v-.008a.15.15 0 0 0-.02-.077a.2.2 0 0 0-.073-.066a1 1 0 0 0-.143-.062a3 3 0 0 0-.233-.062a5 5 0 0 1-.413-.113a1.3 1.3 0 0 1-.331-.16a.7.7 0 0 1-.222-.243a.73.73 0 0 1-.082-.36v-.008a.9.9 0 0 1 .074-.359a.8.8 0 0 1 .214-.283a1 1 0 0 1 .34-.185a1.4 1.4 0 0 1 .448-.066zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08l.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z" />
|
||||
</ButtonIcon>
|
||||
</Button>
|
||||
<Button>
|
||||
<Button comingSoon>
|
||||
<ButtonText>
|
||||
Amazon Games
|
||||
<Soon>Soon</Soon>
|
||||
@@ -508,77 +322,6 @@ export function CreateTeamComponent() {
|
||||
<Link target="_blank" href="https://discord.gg/6um5K6jrYj" >Help I can't connect my account</Link>
|
||||
</Footer>
|
||||
</Card>
|
||||
<Divider />
|
||||
<Card
|
||||
style={{
|
||||
"--nestri-qr-dot-color": theme.color.gray.d100,
|
||||
"--nestri-body-background": theme.color.d1000.gray,
|
||||
"align-items": "center",
|
||||
}}>
|
||||
<QRContainer success={loginSuccess()} login={!loginSuccess() && !!challengeUrl() && !timedOut() && !errorMsg()} error={!loginSuccess() && (timedOut() || !!errorMsg())}>
|
||||
<QRBg />
|
||||
<QRWrapper error={loginSuccess() || timedOut() || !!errorMsg()}>
|
||||
<LogoContainer>
|
||||
<LogoIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={32}
|
||||
height={32}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" />
|
||||
<path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</LogoIcon>
|
||||
</LogoContainer>
|
||||
{(challengeUrl()
|
||||
&& !timedOut()
|
||||
&& !loginSuccess()
|
||||
&& !errorMsg()) ? (<QRCode
|
||||
uri={challengeUrl() as string}
|
||||
size={180}
|
||||
ecl="H"
|
||||
clearArea={true}
|
||||
/>) : (<QRCode
|
||||
uri={"https://nestri.io"}
|
||||
size={180}
|
||||
ecl="H"
|
||||
clearArea={true}
|
||||
/>)}
|
||||
|
||||
</QRWrapper>
|
||||
{(!loginSuccess() && timedOut() || errorMsg()) && (
|
||||
<QRReloadBtn onClick={() => setRetryCount((c) => c + 1)}>
|
||||
<QRRealoadContainer>
|
||||
<QRReloadSvg
|
||||
aria-hidden="true"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M32 16C32 24.8366 24.8366 32 16 32C7.16344 32 0 24.8366 0 16C0 7.16344 7.16344 0 16 0C24.8366 0 32 7.16344 32 16ZM24.5001 8.74263C25.0834 8.74263 25.5563 9.21551 25.5563 9.79883V14.5997C25.5563 15.183 25.0834 15.6559 24.5001 15.6559H19.6992C19.1159 15.6559 18.643 15.183 18.643 14.5997C18.643 14.0164 19.1159 13.5435 19.6992 13.5435H21.8378L20.071 11.8798C20.0632 11.8724 20.0555 11.865 20.048 11.8574C19.1061 10.915 17.8835 10.3042 16.5643 10.1171C15.2452 9.92999 13.9009 10.1767 12.7341 10.82C11.5674 11.4634 10.6413 12.4685 10.0955 13.684C9.54968 14.8994 9.41368 16.2593 9.70801 17.5588C10.0023 18.8583 10.711 20.0269 11.7273 20.8885C12.7436 21.7502 14.0124 22.2582 15.3425 22.336C16.6726 22.4138 17.9919 22.0572 19.1017 21.3199C19.5088 21.0495 19.8795 20.7333 20.2078 20.3793C20.6043 19.9515 21.2726 19.9262 21.7004 20.3228C22.1282 20.7194 22.1534 21.3876 21.7569 21.8154C21.3158 22.2912 20.8176 22.7161 20.2706 23.0795C18.7793 24.0702 17.0064 24.5493 15.2191 24.4448C13.4318 24.3402 11.7268 23.6576 10.3612 22.4998C8.9956 21.3419 8.0433 19.7716 7.6478 18.0254C7.2523 16.2793 7.43504 14.4519 8.16848 12.8186C8.90192 11.1854 10.1463 9.83471 11.7142 8.97021C13.282 8.10572 15.0884 7.77421 16.861 8.02565C18.6282 8.27631 20.2664 9.09278 21.5304 10.3525L23.4439 12.1544V9.79883C23.4439 9.21551 23.9168 8.74263 24.5001 8.74263Z" fill="currentColor" />
|
||||
</QRReloadSvg>
|
||||
</QRRealoadContainer>
|
||||
</QRReloadBtn>
|
||||
)}
|
||||
</QRContainer>
|
||||
<ButtonContainer>
|
||||
<Title>{loginSuccess() ?
|
||||
"Login successful" :
|
||||
(timedOut() || !!errorMsg()) ?
|
||||
"Login timed out" :
|
||||
"Scan to connect Steam"
|
||||
}</Title>
|
||||
<Subtitle>{
|
||||
loginSuccess() ?
|
||||
"Just a minute while we create your team" :
|
||||
(timedOut() || !!errorMsg()) ?
|
||||
"Failed to connect Steam. Please try again." :
|
||||
"On your mobile phone, open the Steam App to scan this code"}</Subtitle>
|
||||
</ButtonContainer>
|
||||
</Card>
|
||||
</CardWrapper>
|
||||
</Container>
|
||||
<LogoFooter >
|
||||
|
||||
@@ -3,7 +3,7 @@ import { A } from "@solidjs/router";
|
||||
import { Text } from "@nestri/www/ui/text";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { Header } from "@nestri/www/pages/steam/header";
|
||||
import { Screen as FullScreen, Container } from "@nestri/www/ui/layout";
|
||||
|
||||
const NotAllowedDesc = styled("div", {
|
||||
|
||||
179
packages/www/src/pages/profiles.tsx
Normal file
179
packages/www/src/pages/profiles.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { keyframes } from "@macaron-css/core";
|
||||
import { useAccount } from "../providers/account";
|
||||
import SteamAvatar from "../components/profile-picture";
|
||||
import { Container, Screen as FullScreen, theme } from "@nestri/www/ui";
|
||||
|
||||
const Background = styled("div", {
|
||||
base: {
|
||||
position: "fixed",
|
||||
zIndex: "-1",
|
||||
inset: 0,
|
||||
":after": {
|
||||
inset: 0,
|
||||
content: "",
|
||||
userSelect: "none",
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
background: `linear-gradient(0deg,${theme.color.background.d200} 30%,transparent),linear-gradient(0deg,${theme.color.background.d200} 30%,transparent)`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const gradient = keyframes({
|
||||
"0%": {
|
||||
backgroundPosition: "0% 50%",
|
||||
},
|
||||
"50%": {
|
||||
backgroundPosition: "100% 50%",
|
||||
},
|
||||
"100%": {
|
||||
backgroundPosition: "0% 50%",
|
||||
},
|
||||
})
|
||||
|
||||
const BackgroundImage = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "70%",
|
||||
position: "relative",
|
||||
filter: "saturate(120%)",
|
||||
backgroundSize: "300% 100%",
|
||||
backgroundPosition: "0% 0%",
|
||||
backgroundRepeat: "repeat-x",
|
||||
animation: `${gradient} 35s linear 0s infinite`,
|
||||
backgroundImage: "linear-gradient(120deg, rgb(232,23,98) 1.26%, rgb(30,134,248) 18.6%, rgb(91,108,255) 34.56%, rgb(52,199,89) 49.76%, rgb(245,197,5) 64.87%, rgb(236,62,62) 85.7%)",
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = styled("div", {
|
||||
base: {
|
||||
margin: "100px 0",
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
maxWidth: 700,
|
||||
}
|
||||
})
|
||||
|
||||
const Title = styled("h1", {
|
||||
base: {
|
||||
fontSize: "50px",
|
||||
fontFamily: theme.font.family.heading,
|
||||
letterSpacing: "-0.515px",
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
const Profiles = styled("div", {
|
||||
base: {
|
||||
// width: "100%",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(150px, auto))",
|
||||
display: "grid",
|
||||
columnGap: 12,
|
||||
rowGap: 10,
|
||||
margin: "100px 0",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}
|
||||
})
|
||||
|
||||
const Profile = styled("div", {
|
||||
base: {
|
||||
width: 150,
|
||||
}
|
||||
})
|
||||
|
||||
const ProfilePicture = styled("div", {
|
||||
base: {
|
||||
width: 150,
|
||||
height: 150,
|
||||
cursor: "pointer",
|
||||
borderRadius: 75,
|
||||
overflow: "hidden",
|
||||
border: `6px solid ${theme.color.gray.d700}`,
|
||||
transition: "all 200ms ease",
|
||||
":hover": {
|
||||
transform: "scale(1.07)",
|
||||
borderColor: theme.color.blue.d700
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ProfileName = styled("div", {
|
||||
base: {
|
||||
margin: "20px 0",
|
||||
lineHeight: "1.25em",
|
||||
color: theme.color.gray.d900,
|
||||
transition: "all 300ms ease",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: theme.font.size.lg
|
||||
}
|
||||
})
|
||||
|
||||
const NewButton = styled(A, {
|
||||
base: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
textDecoration: "none",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
color: "inherit",
|
||||
padding: "0px 14px",
|
||||
gap: 10,
|
||||
width: "max-content",
|
||||
alignSelf: "center",
|
||||
height: 48,
|
||||
borderRadius: theme.space["3"],
|
||||
transition: "all .2s ease",
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
":hover": {
|
||||
transform: "scale(1.02)",
|
||||
borderColor: theme.color.blue.d700
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function ProfilesRoute() {
|
||||
const account = useAccount()
|
||||
return (
|
||||
<FullScreen>
|
||||
<Container
|
||||
vertical="center"
|
||||
horizontal="center"
|
||||
style={{ position: "fixed", height: "100%", width: "100%" }} >
|
||||
<Background>
|
||||
<BackgroundImage />
|
||||
</Background>
|
||||
<Wrapper>
|
||||
<Title>
|
||||
Who's playing?
|
||||
</Title>
|
||||
<Profiles>
|
||||
<For each={account.current.profiles}>
|
||||
{(profile) => (
|
||||
<Profile>
|
||||
<ProfilePicture>
|
||||
<SteamAvatar avatarHash={profile.avatarHash} />
|
||||
</ProfilePicture>
|
||||
<ProfileName>{profile.name}</ProfileName>
|
||||
</Profile>
|
||||
)}
|
||||
</For>
|
||||
</Profiles>
|
||||
<NewButton href="/new" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none"><path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z" /><path fill="currentColor" d="M6 7a5 5 0 1 1 10 0A5 5 0 0 1 6 7m-1.178 7.672C6.425 13.694 8.605 13 11 13q.671 0 1.316.07a1 1 0 0 1 .72 1.557A5.97 5.97 0 0 0 12 18c0 .92.207 1.79.575 2.567a1 1 0 0 1-.89 1.428L11 22c-2.229 0-4.335-.14-5.913-.558c-.785-.208-1.524-.506-2.084-.956C2.41 20.01 2 19.345 2 18.5c0-.787.358-1.523.844-2.139c.494-.625 1.177-1.2 1.978-1.69ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1" /></g></svg>
|
||||
Add Steam account
|
||||
</NewButton>
|
||||
</Wrapper>
|
||||
</Container>
|
||||
</FullScreen>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FullScreen, theme } from "@nestri/www/ui";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { Header } from "@nestri/www/pages/steam/header";
|
||||
import { Modal } from "@nestri/www/ui/modal";
|
||||
import { createEffect, createSignal, Match, onCleanup, Switch } from "solid-js";
|
||||
import { Text } from "@nestri/www/ui/text"
|
||||
@@ -1,15 +1,13 @@
|
||||
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 { ZeroProvider } from "@nestri/www/providers/zero";
|
||||
import { TeamContext } from "@nestri/www/providers/context";
|
||||
import { SteamContext } from "@nestri/www/providers/context";
|
||||
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 = (
|
||||
export const SteamRoute = (
|
||||
<Route
|
||||
// component={(props) => {
|
||||
// const params = useParams();
|
||||
@@ -19,22 +17,22 @@ export const TeamRoute = (
|
||||
|
||||
// const team = createMemo(() =>
|
||||
// account.current.teams.find(
|
||||
// (item) => item.slug === params.teamSlug,
|
||||
// (item) => item.id === params.steamID,
|
||||
// ),
|
||||
// );
|
||||
|
||||
// createEffect(() => {
|
||||
// const t = team();
|
||||
// if (!t) return;
|
||||
// storage.set("team", t.id);
|
||||
// storage.set("steam", t.id);
|
||||
// });
|
||||
|
||||
// createEffect(() => {
|
||||
// const teamSlug = params.teamSlug;
|
||||
// const steamID = params.steamID;
|
||||
// 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);
|
||||
// for (const profile of item.profiles) {
|
||||
// if (profile.id === steamID && item.id !== openauth.subject!.id) {
|
||||
// openauth.switch(item.id);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -48,18 +46,15 @@ export const TeamRoute = (
|
||||
// </Match>
|
||||
// <Match when={team()}>
|
||||
// <TeamContext.Provider value={() => team()!}>
|
||||
// <ZeroProvider>
|
||||
// <ApiProvider>
|
||||
// {props.children}
|
||||
// </ApiProvider>
|
||||
// </ZeroProvider>
|
||||
// <ApiProvider>
|
||||
// {props.children}
|
||||
// </ApiProvider>
|
||||
// </TeamContext.Provider>
|
||||
// </Match>
|
||||
// </Switch>
|
||||
// )
|
||||
// }}
|
||||
>
|
||||
<Route path="" component={HomeRoute} />
|
||||
<Route path="library" component={LibraryRoute} />
|
||||
<Route path="*" component={() => <NotFound header />} />
|
||||
</Route>
|
||||
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
import { Header } from "@nestri/www/pages/steam/header";
|
||||
|
||||
const Container = styled("div", {
|
||||
base: {
|
||||
@@ -9,7 +9,7 @@ function init() {
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore({
|
||||
account: "",
|
||||
team: "",
|
||||
steam: "",
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { hc } from "hono/client";
|
||||
import { useTeam } from "./context";
|
||||
import { useSteam } from "./context";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { type app } from "@nestri/functions/api/index";
|
||||
import { createInitializedContext } from "@nestri/www/common/context";
|
||||
@@ -8,7 +8,7 @@ import { createInitializedContext } from "@nestri/www/common/context";
|
||||
export const { use: useApi, provider: ApiProvider } = createInitializedContext(
|
||||
"ApiContext",
|
||||
() => {
|
||||
const team = useTeam();
|
||||
const steam = useSteam();
|
||||
const auth = useOpenAuth();
|
||||
|
||||
const client = hc<typeof app>(import.meta.env.VITE_API_URL, {
|
||||
@@ -18,7 +18,7 @@ export const { use: useApi, provider: ApiProvider } = createInitializedContext(
|
||||
input instanceof Request ? input : new Request(input, init);
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("authorization", `Bearer ${await auth.access()}`);
|
||||
headers.set("x-nestri-team", team().id);
|
||||
headers.set("x-nestri-steam", steam().id);
|
||||
|
||||
return fetch(
|
||||
new Request(request, {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Accessor, createContext, useContext } from "solid-js";
|
||||
|
||||
export const TeamContext = createContext<Accessor<Team.Info>>();
|
||||
export const SteamContext = createContext<Accessor<Steam.Info>>();
|
||||
|
||||
export function useTeam() {
|
||||
const context = useContext(TeamContext);
|
||||
if (!context) throw new Error("No team context");
|
||||
export function useSteam() {
|
||||
const context = useContext(SteamContext);
|
||||
if (!context) throw new Error("No steam context");
|
||||
return context;
|
||||
}
|
||||
@@ -1,20 +1,15 @@
|
||||
import { useTeam } from "./context"
|
||||
// import { createEffect } from "solid-js"
|
||||
import { schema } from "@nestri/zero/schema"
|
||||
// import { useQuery } from "@rocicorp/zero/solid"
|
||||
import { useOpenAuth } from "@openauthjs/solid"
|
||||
import { Zero } from "@rocicorp/zero"
|
||||
import { schema } from "@nestri/zero/schema"
|
||||
import { useOpenAuth } from "@openauthjs/solid"
|
||||
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 auth = useOpenAuth()
|
||||
const zero = new Zero({
|
||||
schema,
|
||||
storageKey: team().id,
|
||||
auth: () => auth.access(),
|
||||
userID: account.current.id,
|
||||
server: import.meta.env.VITE_ZERO_URL,
|
||||
@@ -27,13 +22,3 @@ export const { use: useZero, provider: ZeroProvider } =
|
||||
ready: true,
|
||||
};
|
||||
});
|
||||
|
||||
// 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)
|
||||
// }
|
||||
@@ -1,175 +0,0 @@
|
||||
import QRCodeUtil from 'qrcode';
|
||||
import { createMemo, type JSXElement } from "solid-js"
|
||||
|
||||
const generateMatrix = (
|
||||
value: string,
|
||||
errorCorrectionLevel: QRCodeUtil.QRCodeErrorCorrectionLevel
|
||||
) => {
|
||||
const arr = Array.prototype.slice.call(
|
||||
QRCodeUtil.create(value, { errorCorrectionLevel }).modules.data,
|
||||
0
|
||||
);
|
||||
const sqrt = Math.sqrt(arr.length);
|
||||
return arr.reduce(
|
||||
(rows, key, index) =>
|
||||
(index % sqrt === 0
|
||||
? rows.push([key])
|
||||
: rows[rows.length - 1].push(key)) && rows,
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ecl?: QRCodeUtil.QRCodeErrorCorrectionLevel;
|
||||
size?: number;
|
||||
uri: string;
|
||||
clearArea?: boolean;
|
||||
image?: HTMLImageElement;
|
||||
imageBackground?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an SVG element displaying a QR code generated from a URI.
|
||||
*
|
||||
* This component creates a QR code matrix based on the provided URI and error correction level, then renders
|
||||
* the QR code using SVG elements. It highlights finder patterns and conditionally renders QR code dots,
|
||||
* while optionally embedding a logo in the center with a specified background and an adjustable clear area.
|
||||
*
|
||||
* @param ecl - The error correction level for the QR code (defaults to 'M').
|
||||
* @param size - The overall size (in pixels) of the QR code, including margins (defaults to 200).
|
||||
* @param uri - The URI to encode into the QR code.
|
||||
* @param clearArea - When true, reserves extra space in the QR code for an embedded logo.
|
||||
* @param image - An optional JSX element to render as a central logo within the QR code.
|
||||
* @param imageBackground - The background color for the logo area (defaults to 'transparent').
|
||||
*
|
||||
* @returns An SVG element representing the generated QR code.
|
||||
*/
|
||||
export function QRCode({
|
||||
ecl = 'M',
|
||||
size: sizeProp = 200,
|
||||
uri,
|
||||
clearArea = false,
|
||||
image,
|
||||
imageBackground = 'transparent',
|
||||
}: Props) {
|
||||
const logoSize = clearArea ? 38 : 0;
|
||||
const size = sizeProp - 10 * 2;
|
||||
|
||||
const dots = createMemo(() => {
|
||||
const dots: JSXElement[] = [];
|
||||
const matrix = generateMatrix(uri, ecl);
|
||||
const cellSize = size / matrix.length;
|
||||
let qrList = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
];
|
||||
|
||||
qrList.forEach(({ x, y }) => {
|
||||
const x1 = (matrix.length - 7) * cellSize * x;
|
||||
const y1 = (matrix.length - 7) * cellSize * y;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
dots.push(
|
||||
<rect
|
||||
id={`${i}-${x}-${y}`}
|
||||
fill={
|
||||
i % 2 !== 0
|
||||
? 'var(--nestri-qr-background, var(--nestri-body-background))'
|
||||
: 'var(--nestri-qr-dot-color)'
|
||||
}
|
||||
rx={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||
ry={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||
width={cellSize * (7 - i * 2)}
|
||||
height={cellSize * (7 - i * 2)}
|
||||
x={x1 + cellSize * i}
|
||||
y={y1 + cellSize * i}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (image) {
|
||||
const x1 = (matrix.length - 7) * cellSize * 1;
|
||||
const y1 = (matrix.length - 7) * cellSize * 1;
|
||||
dots.push(
|
||||
<>
|
||||
<rect
|
||||
fill={imageBackground}
|
||||
rx={(0 - 2) * -5 + 2}
|
||||
ry={(0 - 2) * -5 + 2}
|
||||
width={cellSize * (7 - 0 * 2)}
|
||||
height={cellSize * (7 - 0 * 2)}
|
||||
x={x1 + cellSize * 0}
|
||||
y={y1 + cellSize * 0}
|
||||
/>
|
||||
<foreignObject
|
||||
width={cellSize * (7 - 0 * 2)}
|
||||
height={cellSize * (7 - 0 * 2)}
|
||||
x={x1 + cellSize * 0}
|
||||
y={y1 + cellSize * 0}
|
||||
>
|
||||
<div style={{ "border-radius": `${(0 - 2) * -5 + 2}px`, overflow: 'hidden' }}>
|
||||
{image}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const clearArenaSize = Math.floor((logoSize + 25) / cellSize);
|
||||
const matrixMiddleStart = matrix.length / 2 - clearArenaSize / 2;
|
||||
const matrixMiddleEnd = matrix.length / 2 + clearArenaSize / 2 - 1;
|
||||
|
||||
matrix.forEach((row: QRCodeUtil.QRCode[], i: number) => {
|
||||
row.forEach((_: any, j: number) => {
|
||||
if (matrix[i][j]) {
|
||||
// Do not render dots under position squares
|
||||
if (
|
||||
!(
|
||||
(i < 7 && j < 7) ||
|
||||
(i > matrix.length - 8 && j < 7) ||
|
||||
(i < 7 && j > matrix.length - 8)
|
||||
)
|
||||
) {
|
||||
if (
|
||||
image ||
|
||||
!(
|
||||
i > matrixMiddleStart &&
|
||||
i < matrixMiddleEnd &&
|
||||
j > matrixMiddleStart &&
|
||||
j < matrixMiddleEnd
|
||||
)
|
||||
) {
|
||||
dots.push(
|
||||
<circle
|
||||
id={`circle-${i}-${j}`}
|
||||
cx={i * cellSize + cellSize / 2}
|
||||
cy={j * cellSize + cellSize / 2}
|
||||
fill="var(--nestri-qr-dot-color)"
|
||||
r={cellSize / 3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return dots;
|
||||
}, [ecl, size, uri]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}}
|
||||
>
|
||||
<rect fill="transparent" height={size} width={size} />
|
||||
{dots()}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "@nestri/zero",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@rocicorp/zero": "*",
|
||||
"@nestri/core": "*"
|
||||
"@nestri/core": "*",
|
||||
"@rocicorp/zero": "0.20.2025051800"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "zero-deploy-permissions && zero-cache",
|
||||
"generate": "zero-deploy-permissions --output-format=sql --output-file=permissions.sql"
|
||||
}
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@rocicorp/zero-sqlite3"
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Size } from "@nestri/core/src/base-game/base-game.sql";
|
||||
import { type Limitations } from "@nestri/core/src/steam/steam.sql";
|
||||
import { ImageColor, ImageDimensions } from "@nestri/core/src/images/images.sql";
|
||||
import type { Limitations } from "@nestri/core/src/steam/steam.sql";
|
||||
import type { Size, Links } from "@nestri/core/src/base-game/base-game.sql";
|
||||
import type { ImageColor, ImageDimensions } from "@nestri/core/src/images/images.sql";
|
||||
import {
|
||||
json,
|
||||
table,
|
||||
number,
|
||||
string,
|
||||
boolean,
|
||||
enumeration,
|
||||
createSchema,
|
||||
relationships,
|
||||
@@ -38,7 +37,6 @@ const steam_accounts = table("steam_accounts")
|
||||
name: string(),
|
||||
status: string(),
|
||||
user_id: string(),
|
||||
username: string(),
|
||||
avatar_hash: string(),
|
||||
member_since: number(),
|
||||
last_synced_at: number(),
|
||||
@@ -49,28 +47,6 @@ const steam_accounts = table("steam_accounts")
|
||||
})
|
||||
.primaryKey("id");
|
||||
|
||||
const teams = table("teams")
|
||||
.columns({
|
||||
id: string(),
|
||||
name: string(),
|
||||
slug: string(),
|
||||
owner_id: string(),
|
||||
invite_code: string(),
|
||||
max_members: number(),
|
||||
...timestamps,
|
||||
})
|
||||
.primaryKey("id");
|
||||
|
||||
const members = table("members")
|
||||
.columns({
|
||||
role: string(),
|
||||
team_id: string(),
|
||||
steam_id: string(),
|
||||
user_id: string().optional(),
|
||||
...timestamps,
|
||||
})
|
||||
.primaryKey("team_id", "steam_id");
|
||||
|
||||
const friends_list = table("friends_list")
|
||||
.columns({
|
||||
steam_id: string(),
|
||||
@@ -83,7 +59,7 @@ const games = table("games")
|
||||
.columns({
|
||||
base_game_id: string(),
|
||||
category_slug: string(),
|
||||
type: enumeration<"tag" | "genre" | "publisher" | "developer">(),
|
||||
type: enumeration<"tag" | "genre" | "publisher" | "developer" | "categorie" | "franchise">(),
|
||||
...timestamps
|
||||
})
|
||||
.primaryKey("category_slug", "base_game_id", "type")
|
||||
@@ -93,9 +69,11 @@ const base_games = table("base_games")
|
||||
id: string(),
|
||||
slug: string(),
|
||||
name: string(),
|
||||
release_date: number(),
|
||||
// This should be an array, and i dunno how to include it here
|
||||
size: json<Size>(),
|
||||
description: string(),
|
||||
release_date: number(),
|
||||
links: json<Links>().optional(),
|
||||
description: string().optional(),
|
||||
primary_genre: string().optional(),
|
||||
controller_support: enumeration<"full" | "partial" | "unknown">(),
|
||||
compatibility: enumeration<"high" | "mid" | "low" | "unknown">(),
|
||||
@@ -116,13 +94,11 @@ const categories = table("categories")
|
||||
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(),
|
||||
owner_steam_id: string(),
|
||||
last_played: number().optional(),
|
||||
...timestamps
|
||||
}).primaryKey("base_game_id", "owner_id")
|
||||
}).primaryKey("base_game_id", "owner_steam_id")
|
||||
|
||||
const images = table("images")
|
||||
.columns({
|
||||
@@ -133,11 +109,11 @@ const images = table("images")
|
||||
dimensions: json<ImageDimensions>(),
|
||||
extracted_color: json<ImageColor>(),
|
||||
...timestamps
|
||||
}).primaryKey("image_hash", "type", "base_game_id", "position")
|
||||
}).primaryKey("image_hash")
|
||||
|
||||
// Schema and Relationships
|
||||
export const schema = createSchema({
|
||||
tables: [users, steam_accounts, teams, members, friends_list, categories, base_games, games, game_libraries, images],
|
||||
tables: [users, steam_accounts, friends_list, categories, base_games, games, game_libraries, images],
|
||||
relationships: [
|
||||
relationships(steam_accounts, (r) => ({
|
||||
user: r.one({
|
||||
@@ -145,11 +121,6 @@ export const schema = createSchema({
|
||||
destSchema: users,
|
||||
destField: ["id"],
|
||||
}),
|
||||
memberEntries: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: members,
|
||||
destField: ["steam_id"],
|
||||
}),
|
||||
friends: r.many(
|
||||
{
|
||||
sourceField: ["id"],
|
||||
@@ -166,7 +137,7 @@ export const schema = createSchema({
|
||||
{
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["owner_id"],
|
||||
destField: ["owner_steam_id"],
|
||||
},
|
||||
{
|
||||
sourceField: ["base_game_id"],
|
||||
@@ -176,51 +147,12 @@ export const schema = createSchema({
|
||||
),
|
||||
})),
|
||||
relationships(users, (r) => ({
|
||||
teams: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: teams,
|
||||
destField: ["owner_id"],
|
||||
}),
|
||||
members: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: members,
|
||||
destField: ["user_id"],
|
||||
}),
|
||||
steamAccounts: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["user_id"]
|
||||
})
|
||||
})),
|
||||
relationships(teams, (r) => ({
|
||||
owner: r.one({
|
||||
sourceField: ["owner_id"],
|
||||
destSchema: users,
|
||||
destField: ["id"],
|
||||
}),
|
||||
members: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: members,
|
||||
destField: ["team_id"],
|
||||
}),
|
||||
})),
|
||||
relationships(members, (r) => ({
|
||||
team: r.one({
|
||||
sourceField: ["team_id"],
|
||||
destSchema: teams,
|
||||
destField: ["id"],
|
||||
}),
|
||||
user: r.one({
|
||||
sourceField: ["user_id"],
|
||||
destSchema: users,
|
||||
destField: ["id"],
|
||||
}),
|
||||
steamAccount: r.one({
|
||||
sourceField: ["steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
relationships(base_games, (r) => ({
|
||||
libraryOwners: r.many(
|
||||
{
|
||||
@@ -229,7 +161,7 @@ export const schema = createSchema({
|
||||
destField: ["base_game_id"],
|
||||
},
|
||||
{
|
||||
sourceField: ["owner_id"],
|
||||
sourceField: ["owner_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"],
|
||||
}
|
||||
@@ -289,7 +221,7 @@ export const schema = createSchema({
|
||||
|
||||
relationships(game_libraries, (r) => ({
|
||||
owner: r.one({
|
||||
sourceField: ["owner_id"],
|
||||
sourceField: ["owner_steam_id"],
|
||||
destSchema: steam_accounts,
|
||||
destField: ["id"]
|
||||
}),
|
||||
@@ -327,30 +259,12 @@ type Auth = {
|
||||
|
||||
export const permissions = definePermissions<Auth, Schema>(schema, () => {
|
||||
return {
|
||||
members: {
|
||||
row: {
|
||||
select: [
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'members'>) => q.exists("user", (u) => u.where("id", auth.sub)),
|
||||
//allow other team members to view other members
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'members'>) => q.exists("team", (u) => u.related("members", (m) => m.where("user_id", auth.sub))),
|
||||
]
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
row: {
|
||||
select: [
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'teams'>) => q.exists("members", (u) => u.where("user_id", auth.sub)),
|
||||
]
|
||||
},
|
||||
},
|
||||
steam_accounts: {
|
||||
row: {
|
||||
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.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)))),
|
||||
]
|
||||
},
|
||||
},
|
||||
@@ -373,8 +287,6 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
|
||||
row: {
|
||||
select: [
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.where("user_id", auth.sub)),
|
||||
//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.where("user_id", auth.sub))),
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user