13 Commits

Author SHA1 Message Date
Wanjohi
5fd5608e6e feat: Use CF 2025-06-06 09:09:11 +03:00
Wanjohi
a47dc91b22 feat: Add image transforms 2025-06-05 03:39:15 +03:00
Wanjohi
0124af1b70 feat: Create image pipeline 2025-06-04 13:50:06 +03:00
Wanjohi
e67a8d2b32 feat: Upgrade to asynchronous event bus with retry queue and backoff strategy (#290)
## Description
<!-- Briefly describe the purpose and scope of your changes -->


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

- **New Features**
- Introduced a retry and dead-letter queue system for more robust event
processing.
- Added a retry handler for processing failed Lambda invocations with
exponential backoff.
- Enhanced event handling to support retry logic and improved error
management.

- **Refactor**
- Replaced SQS-based library event processing with an event bus-based
approach.
- Updated event names and structure for improved clarity and
consistency.
  - Removed legacy library queue and related infrastructure.

- **Chores**
  - Updated dependencies to include the AWS Lambda client.
  - Cleaned up unused code and removed deprecated event handling logic.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-04 07:53:30 +03:00
Wanjohi
8f4bb05143 🐜 fix(infra): Reduce DB ACUs 2025-06-03 08:10:41 +03:00
Wanjohi
84357ac5bf 🐜 fix(zero): Remove team and members schemas (#289)
## Description
<!-- Briefly describe the purpose and scope of your changes -->


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

## Summary by CodeRabbit

- **New Features**
- Introduced a new database schema with tables for games, users, Steam
accounts, categories, friends lists, images, and game libraries.
- Added new enumerated types for compatibility, controller support,
category type, image type, and Steam status.

- **Refactor**
- Removed all team and membership-related features, including tables,
relationships, and access permissions.
  - Simplified the primary key structure of the images table.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-02 09:44:58 +03:00
Wanjohi
e11012e8d9 🐜 fix(db): Remove all team associations (#288)
## Description
<!-- Briefly describe the purpose and scope of your changes -->


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

## Summary by CodeRabbit

- **New Features**
- Introduced a new database schema supporting tables for games,
categories, friends lists, images, game libraries, Steam accounts, and
users, with improved relationships and constraints.
- Added new enum types to enhance data consistency for game
compatibility, controller support, category type, image type, and Steam
status.

- **Chores**
  - Updated migration history to reflect the latest schema changes.

- **Revert**
- Removed the previous "members" and "teams" tables and related enum
types from the database.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-02 09:35:11 +03:00
Wanjohi
c0194ecef4 🔄 refactor(steam): Migrate to Steam OpenID authentication and official Web API (#282)
## Description
<!-- Briefly describe the purpose and scope of your changes -->


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

- **New Features**
- Added support for managing multiple Steam profiles per user, including
a new profiles page with avatar selection and profile management.
- Introduced a streamlined Steam authentication flow using a popup
window, replacing the previous QR code and team-based login.
- Added utilities for Steam image handling and metadata, including
avatar preloading and static Steam metadata mappings.
  - Enhanced OpenID verification for Steam login.
- Added new image-related events and expanded event handling for Steam
account updates and image processing.

- **Improvements**
- Refactored the account structure from teams to profiles, updating
related UI, context, and storage.
- Updated API headers and authentication logic to use Steam IDs instead
of team IDs.
- Expanded game metadata with new fields for categories, franchises, and
social links.
- Improved library and category schemas for richer game and profile
data.
- Simplified and improved Steam API client methods for fetching user
info, friends, and game libraries using Steam Web API.
- Updated queue processing to handle individual game updates and publish
image events.
- Adjusted permissions and queue configurations for better message
handling and dead-letter queue support.
  - Improved slug creation and rating estimation utilities.

- **Bug Fixes**
- Fixed avatar image loading to display higher quality images after
initial load.

- **Removals**
- Removed all team, member, and credential management functionality and
related database schemas.
  - Eliminated the QR code-based login and related UI components.
  - Deleted legacy team and member database tables and related code.
- Removed encryption utilities and deprecated secret keys in favor of
new secret management.

- **Chores**
- Updated dependencies and internal configuration for new features and
schema changes.
- Cleaned up unused code and updated database migrations for new data
structures.
- Adjusted import orders and removed unused imports across multiple
modules.
- Added new resource declarations and updated service link
configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-02 09:22:18 +03:00
Kristian Ollikainen
ae364f69bd feat(runner): Improve robustness and argument handling (#285)
## Description
Made argument parsing and handling much nicer with clap features.
Changed to tracing package for logging and made other improvements
around to hopefully make things more robust and logical.

Default audio-capture-method is now PipeWire since it seems to work
perfectly fine with latest gstreamer 🎉

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

- **New Features**
- Improved command-line argument parsing with stricter validation, type
safety, and clearer help messages.
- Enhanced GPU selection and logging, including explicit GPU info
logging and support for negative GPU indices for auto-selection.
- Added support for new audio and video codec and encoder enums,
providing safer and more flexible codec handling.

- **Bug Fixes**
- Improved error handling and logging throughout the application,
unifying logs under the `tracing` system for better diagnostics.
- Fixed issues with directory ownership and environment variable
handling in startup scripts.

- **Refactor**
- Replaced string-based parsing and manual conversions with strongly
typed enums and value parsers.
- Updated logging from `println!` and `log` macros to the `tracing`
crate for consistency.
- Simplified and unified the handling of pipeline and element references
in the signaling and data channel logic.

- **Chores**
- Updated and cleaned up dependencies, including switching from `log` to
`tracing` and upgrading the `webrtc` crate.
- Removed unused or redundant code and environment variables for
improved maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
2025-05-23 11:33:40 +03:00
Kristian Ollikainen
d7e6da12ac feat(runner): DMA-BUF support for Intel/AMD GPUs (#283)
## Description
Adds DMA-BUF support for non-NVIDIA GPUs using GL elements as conversion
workaround.

Tested with QSV and VA encoders, note that H.264 seems to only work for
QSV encoder, for `vah264(lp)enc` theres major CPU usage with DMA-BUF
enabled.

Don't mind the branch name, I was working on relay before and changed
gears to runner after noticing some DMA-BUF stuff 😅

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

- **Chores**
- Added a `.dockerignore` file to exclude the `target` directory from
Docker builds.
	- Updated `.gitignore` to ignore the `target` directory.

- **New Features**
- Enhanced video processing pipeline with updated handling of DMA-BUF
support, including improved compatibility for different GPU vendors and
refined video element configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
2025-05-20 08:50:24 +03:00
Wanjohi
6e19b2e9a0 🐜 fix(zero): Remove unnecessary StorageKey value 2025-05-19 06:26:11 +03:00
Wanjohi
dd20c0049d 🐜 fix(zero): Fix zero throwing error about tables being undefined (#281)
## Description
<!-- Briefly describe the purpose and scope of your changes -->


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

- **New Features**
- Introduced a new environment variable for improved configuration
options.

- **Chores**
  - Updated and locked dependency versions for enhanced stability.
- Marked certain packages as private to prevent accidental publication.
- Updated package metadata and trusted dependencies for better
dependency management.

- **Refactor**
- Adjusted provider structure in the app to wrap children components
with an additional context provider.
  - Simplified and cleaned up provider context code for maintainability.
  - Improved import statements for clarity and type safety.

- **Style**
  - Reorganized import order for consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-19 06:23:50 +03:00
Wanjohi
14e4176344 🐜 fix(www): Uncomment the SST parts 2025-05-17 23:32:15 +03:00
102 changed files with 10916 additions and 3020 deletions

879
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -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} && \

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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
}

View File

@@ -1,14 +1,9 @@
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
: {
scaling: {
min: "0 ACU",
max: "1 ACU",
},
@@ -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",

View File

@@ -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,
},
);

View File

@@ -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
],
});

View File

@@ -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",
}),

View File

@@ -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"
}
}

View 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";

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."category_type" ADD VALUE 'category';--> statement-breakpoint
ALTER TYPE "public"."category_type" ADD VALUE 'franchise';

View 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";

View 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[];

View File

@@ -0,0 +1 @@
ALTER TABLE "base_games" ALTER COLUMN "links" SET DATA TYPE json;

View File

@@ -0,0 +1,3 @@
DROP TABLE "members" CASCADE;--> statement-breakpoint
DROP TABLE "teams" CASCADE;--> statement-breakpoint
DROP TYPE "public"."member_role";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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>();

View File

@@ -9,9 +9,12 @@ 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"),

View File

@@ -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,

View File

@@ -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",

View File

@@ -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: []
})
}
}

View File

@@ -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([
try {
const info = 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`
),
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;
}
}
}

View 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",
}
}

View File

@@ -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;
}

View File

@@ -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;
}
return { downloadSize: sum.download, sizeOnDisk: sum.size };
download += dl;
size += sz;
}
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 }])
);
}
}

View File

@@ -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.

View File

@@ -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]
})
]
)

View File

@@ -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,
};
}
}

View File

@@ -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(),

View File

@@ -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 = [

View File

@@ -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 {

View File

@@ -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)
)
)

View File

@@ -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),
],
);

View File

@@ -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
};
}
}

View File

@@ -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))
],
);

View File

@@ -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,
};
}

View File

@@ -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)
]
}
);

View File

@@ -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))
})),
)
}
}

View File

@@ -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)
]
);

View File

@@ -1,6 +1,4 @@
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";
@@ -9,7 +7,7 @@ import { userTable } from "./user.sql";
import { Examples } from "../examples";
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

View File

@@ -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"

View File

@@ -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');
});
}

View File

@@ -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"
}
}

View File

@@ -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] }
})
),
},

View 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),
},
)
}
)
}

View File

@@ -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" },

View File

@@ -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 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." }),
})
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
});
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)
}
)
}

View File

@@ -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
})
}
)
}

View File

@@ -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)
});

View File

@@ -1,74 +1,35 @@
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;
const cookies = await session.getWebCookies();
const friends = await Client.getFriendsList(cookies);
const putFriends = friends.map(async (user) => {
const wasAdded =
await Steam.create({
id: user.steamID.toString(),
name: user.name,
realName: user.realName,
avatarHash: user.avatarHash,
steamMemberSince: user.memberSince,
profileUrl: user.customURL?.trim() || null,
limitations: {
isLimited: user.isLimitedAccount,
isVacBanned: user.vacBanned,
tradeBanState: user.tradeBanState.toLowerCase() as any,
privacyState: user.privacyState as any,
visibilityState: Number(user.visibilityState)
}
})
if (!wasAdded) {
console.log(`Steam user ${user.steamID.toString()} already exists`)
}
await Friend.add({ friendSteamID: user.steamID.toString(), steamID: input.steamID })
})
const settled = await Promise.allSettled(putFriends);
settled
.filter(result => result.status === 'rejected')
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
}
break;
}
case "new_game.added": {
const input = event.properties
// Get images and save to s3
const images = await Client.getImages(input.appID);
(await Promise.allSettled(
images.map(async (image) => {
// Put the images into the db
await Images.create({
type: image.type,
imageHash: image.hash,
@@ -105,10 +66,201 @@ export const handler = bus.subscriber(
)
}
break;
}
case "new_box_art_image.save": {
const input = event.properties;
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) => {
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 "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,
})
})
))
.filter(i => i.status === "rejected")
.forEach(r => console.warn("[createImages] failed:", (r as PromiseRejectedResult).reason));
break;
}

View 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")
}
)
}

View 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;

View 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" })
}

View File

@@ -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));
}
)
}
}

View 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;
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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/

View File

@@ -0,0 +1 @@
target/

View File

@@ -1 +1,2 @@
*.mp4
target/

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"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)),
}
}
}
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"h264" | "h.264" | "avc" => Self::H264,
"h265" | "h.265" | "hevc" | "hev1" => Self::H265,
"av1" => Self::AV1,
_ => Self::UNKNOWN,
#[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())
}
}

View File

@@ -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())
}
}

View File

@@ -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 = match handle_gpus(&args) {
Ok(gpu) => gpu,
Err(e) => {
tracing::error!("Failed to find a suitable GPU: {}", e);
return Err(e);
}
let gpu = gpu.unwrap();
};
// 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..");
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 = match handle_encoder_video(&args) {
Ok(encoder) => encoder,
Err(e) => {
tracing::error!("Failed to find a suitable video encoder: {}", e);
return Err(e);
}
let mut video_encoder_info = video_encoder_info.unwrap();
};
// 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 {
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,7 +401,7 @@ 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
if *gpu.vendor() == GPUVendor::NVIDIA {
gst::Element::link_many(&[
&video_source,
&caps_filter,
@@ -401,7 +413,19 @@ async fn main() -> Result<(), Box<dyn Error>> {
&video_encoder,
])?;
} else {
// Link video source to caps_filter, video_converter, video_encoder, webrtcsink
gst::Element::link_many(&[
&video_source,
&caps_filter,
&video_queue,
&video_clocksync,
&glupload,
&glcolorconvert,
&gl_caps_filter,
&gl_download,
&video_encoder,
])?;
}
} else {
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) => {

View File

@@ -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)
}

View File

@@ -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);
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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&hellip;</Text>
// </FullScreen>
// }>
// {props.children}
props.children
// </AccountProvider>
<AccountProvider
loadingUI={
<FullScreen>
<Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity&hellip;</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>
)
}

View 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,
}}
/>
);
}

View File

@@ -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);
let currentStream: EventSource | null = null;
const connectStream = async () => {
// clear previous state
setChallengeUrl(null);
setTimedOut(false);
setErrorMsg(null);
if (currentStream) {
currentStream.close();
}
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}`,
},
}),
}
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
currentStream = stream;
// status
// stream.addEventListener("status", (e) => {
// // setStatus(JSON.parse(e.data).message);
// });
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"
};
// challenge URL
stream.addEventListener("challenge_url", (e) => {
setChallengeUrl(JSON.parse(e.data).url);
});
const top = window.top!.outerHeight / 2 + window.top!.screenY - (config.height / 2);
const left = window.top!.outerWidth / 2 + window.top!.screenX - (config.width / 2);
// success
stream.addEventListener("login_success", (e) => {
setLoginSuccess(true);
});
return window.open(
authUrl,
'Steam Popup',
`width=${config.width},height=${config.height},left=${left},top=${top},${config.features}`
);
};
// timed out
stream.addEventListener("timed_out", (e) => {
setTimedOut(true);
});
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);
// server-side error
stream.addEventListener("error", (e: any) => {
// Networklevel errors also fire here
try {
const err = JSON.parse(e.data).message
setErrorMsg(err);
} catch {
setErrorMsg("Connection error");
const poll = setInterval(async () => {
if (targetWindow.closed) {
await cleanup();
resolve(); // Auth window closed by user
}
//Event source has inbuilt retry method,this is to prevent it from firing
stream.close()
});
}, pollInterval);
// team slug
stream.addEventListener("team_slug", async (e) => {
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)
{/**FIXME: Somehow this does not work when the user is in the "/new" page */ }
nav(`/${JSON.parse(e.data).username}`)
nav("/profiles")
window.focus();
}
});
};
// 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());
});
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');
}
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 >

View File

@@ -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", {

View 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>
)
}

View File

@@ -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"

View File

@@ -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>
// </TeamContext.Provider>
// </Match>
// </Switch>
// )
// }}
>
<Route path="" component={HomeRoute} />
<Route path="library" component={LibraryRoute} />
<Route path="*" component={() => <NotFound header />} />
</Route>

View File

@@ -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: {

View File

@@ -9,7 +9,7 @@ function init() {
const [store, setStore] = makePersisted(
createStore({
account: "",
team: "",
steam: "",
})
);

View File

@@ -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, {

View File

@@ -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;
}

View File

@@ -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)
// }

View File

@@ -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>
);
}

View File

@@ -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"
]
}

View File

@@ -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