mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-11 00:05:36 +02:00
⭐feat: Implement Game Image Support with Metadata & Schema Updates (#277)
## 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 support for associating rich image metadata (color, dimensions, file size) with games, organized by categories like screenshots, box art, posters, hero art, backgrounds, logos, and icons. - Game and library listings now include related image collections for enhanced browsing and detail views. - **Improvements** - Updated game library management to use a consistent base game identifier, improving data consistency and reliability. - Enhanced data schemas and access permissions to allow public viewing of game images and refined access control for game libraries. - Added comprehensive database schema updates for games, categories, images, and libraries to support new features and ensure data integrity. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
89
packages/core/migrations/0011_simple_azazel.sql
Normal file
89
packages/core/migrations/0011_simple_azazel.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
CREATE TYPE "public"."compatibility" AS ENUM('high', 'mid', 'low', 'unknown');--> statement-breakpoint
|
||||
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer');--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'background');--> statement-breakpoint
|
||||
CREATE TABLE "base_games" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"release_date" timestamp with time zone NOT NULL,
|
||||
"size" json NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"primary_genre" text NOT NULL,
|
||||
"controller_support" text,
|
||||
"compatibility" "compatibility" DEFAULT 'unknown' NOT NULL,
|
||||
"score" numeric(2, 1) NOT NULL,
|
||||
CONSTRAINT "idx_base_games_slug" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"type" "category_type" NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
CONSTRAINT "categories_slug_type_pk" PRIMARY KEY("slug","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"category_slug" varchar(255) NOT NULL,
|
||||
"type" "category_type" NOT NULL,
|
||||
CONSTRAINT "games_base_game_id_category_slug_type_pk" PRIMARY KEY("base_game_id","category_slug","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "images" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"type" "image_type" NOT NULL,
|
||||
"image_hash" varchar(255) NOT NULL,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"source_url" text NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"dimensions" json NOT NULL,
|
||||
"extracted_color" json NOT NULL,
|
||||
CONSTRAINT "images_image_hash_type_base_game_id_position_pk" PRIMARY KEY("image_hash","type","base_game_id","position")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "game_libraries" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"owner_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "game_libraries_base_game_id_owner_id_pk" PRIMARY KEY("base_game_id","owner_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" RENAME COLUMN "steam_id" TO "id";--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "members" DROP CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "images" ADD CONSTRAINT "images_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_categories_type" ON "categories" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_slug" ON "games" USING btree ("category_slug");--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_type" ON "games" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_images_type" ON "images" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_images_game_id" ON "images" USING btree ("base_game_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_id");--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
|
||||
CREATE INDEX "idx_friends_list_friend_steam_id" ON "friends_list" USING btree ("friend_steam_id");
|
||||
4
packages/core/migrations/0012_glorious_jetstream.sql
Normal file
4
packages/core/migrations/0012_glorious_jetstream.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "games" DROP CONSTRAINT "games_categories_fkey";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_slug_type" ON "games" USING btree ("category_slug","type");
|
||||
1235
packages/core/migrations/meta/0011_snapshot.json
Normal file
1235
packages/core/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1256
packages/core/migrations/meta/0012_snapshot.json
Normal file
1256
packages/core/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,20 @@
|
||||
"when": 1746726715456,
|
||||
"tag": "0010_certain_dust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1746904821461,
|
||||
"tag": "0011_simple_azazel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1746905730079,
|
||||
"tag": "0012_glorious_jetstream",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -207,17 +207,60 @@ export namespace Examples {
|
||||
],
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
...BaseGame,
|
||||
...Categories
|
||||
export const CommonImg = [
|
||||
{
|
||||
hash: "db880dc2f0187bfe0c5d3c44a06d1002351eb3107970a83bf5667ffd3b369acd",
|
||||
averageColor: {
|
||||
hex: "#352c36",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 3840,
|
||||
height: 2160
|
||||
},
|
||||
fileSize: 976004
|
||||
},
|
||||
{
|
||||
hash: "99f603e41dd3efde21a145fd00c9f107025c09433c084a5e5005bc2ac30e46ea",
|
||||
averageColor: {
|
||||
hex: "#596774",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 2560,
|
||||
height: 1440
|
||||
},
|
||||
fileSize: 895134
|
||||
},
|
||||
{
|
||||
hash: "2c4193c19160392be01d08e6957ed682649117742c5abaa8c469e7408382572f",
|
||||
averageColor: {
|
||||
hex: "#444b5b",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 2560,
|
||||
height: 1440
|
||||
},
|
||||
fileSize: 738701
|
||||
}
|
||||
]
|
||||
|
||||
// type: "screenshots" as const, // or boxart(square), poster(vertical), superheroart(background), heroart(horizontal), logo, icon
|
||||
export const Images = {
|
||||
screenshots: CommonImg,
|
||||
boxArts: CommonImg,
|
||||
posters: CommonImg,
|
||||
heroArts: CommonImg,
|
||||
superHeroArts: CommonImg,
|
||||
backgrounds: CommonImg,
|
||||
logos: CommonImg,
|
||||
icons: CommonImg,
|
||||
}
|
||||
|
||||
// export const image = {
|
||||
// type: "screenshot" as const, // or square, vertical, horizontal, movie
|
||||
// hash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19",
|
||||
// gameID: Game.id,
|
||||
// extractedColors: [{}]
|
||||
// }
|
||||
|
||||
|
||||
export const Game = {
|
||||
...BaseGame,
|
||||
...Categories,
|
||||
...Images
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
import { categoriesTable, CategoryTypeEnum } from "../categories/categories.sql";
|
||||
import { foreignKey, index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const gamesTable = pgTable(
|
||||
'games',
|
||||
@@ -13,21 +13,23 @@ export const gamesTable = pgTable(
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
categorySlug: varchar('category_slug', { length: 255 })
|
||||
.notNull()
|
||||
.references(() => categoriesTable.slug,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
categoryType: varchar('category_type', { length: 255 })
|
||||
.notNull()
|
||||
.references(() => categoriesTable.type,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
.notNull(),
|
||||
categoryType: CategoryTypeEnum("type").notNull()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.categorySlug, table.categoryType]
|
||||
}),
|
||||
foreignKey({
|
||||
name: "games_categories_fkey",
|
||||
columns: [table.categorySlug, table.categoryType],
|
||||
foreignColumns: [categoriesTable.slug, categoriesTable.type],
|
||||
}).onDelete("cascade"),
|
||||
index("idx_games_category_slug").on(table.categorySlug),
|
||||
index("idx_games_category_type").on(table.categoryType),
|
||||
index("idx_games_category_slug_type").on(
|
||||
table.categorySlug,
|
||||
table.categoryType
|
||||
)
|
||||
]
|
||||
);
|
||||
@@ -1,11 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Images } from "../images";
|
||||
import { Examples } from "../examples";
|
||||
import { BaseGame } from "../base-game";
|
||||
import { gamesTable } from "./game.sql";
|
||||
import { Categories } from "../categories";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { imagesTable } from "../images/images.sql";
|
||||
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
@@ -13,7 +15,7 @@ import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Game {
|
||||
export const Info = z
|
||||
.intersection(BaseGame.Info, Categories.Info)
|
||||
.intersection(BaseGame.Info, Categories.Info, Images.Info)
|
||||
.openapi({
|
||||
ref: "Game",
|
||||
description: "Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata",
|
||||
@@ -65,6 +67,7 @@ export namespace Game {
|
||||
.select({
|
||||
games: baseGamesTable,
|
||||
categories: categoriesTable,
|
||||
images: imagesTable
|
||||
})
|
||||
.from(gamesTable)
|
||||
.innerJoin(baseGamesTable,
|
||||
@@ -76,6 +79,12 @@ export namespace Game {
|
||||
eq(categoriesTable.type, gamesTable.categoryType),
|
||||
)
|
||||
)
|
||||
.leftJoin(imagesTable,
|
||||
and(
|
||||
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
||||
isNull(imagesTable.timeDeleted),
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(gamesTable.baseGameID, gameID),
|
||||
@@ -88,7 +97,7 @@ export namespace Game {
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null }[],
|
||||
input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null; images: typeof imagesTable.$inferSelect | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
@@ -100,10 +109,16 @@ export namespace Game {
|
||||
group.map(r => r.categories).filter((c): c is typeof categoriesTable.$inferSelect => Boolean(c)),
|
||||
(c) => `${c.slug}:${c.type}`
|
||||
)
|
||||
const imgs = uniqueBy(
|
||||
group.map(r => r.images).filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)),
|
||||
(c) => `${c.type}:${c.imageHash}:${c.position}`
|
||||
)
|
||||
const byType = Categories.serialize(cats)
|
||||
const byImg = Images.serialize(imgs)
|
||||
return {
|
||||
...game,
|
||||
...byType,
|
||||
...byImg
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
46
packages/core/src/images/images.sql.ts
Normal file
46
packages/core/src/images/images.sql.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, integer, json, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
import { z } from "zod";
|
||||
|
||||
export const ImageTypeEnum = pgEnum("image_type", ["heroArt", "icon", "logo", "superHeroArt", "poster", "boxArt", "screenshot","background"])
|
||||
|
||||
export const ImageDimensions = z.object({
|
||||
width: z.number().int(),
|
||||
height: z.number().int(),
|
||||
})
|
||||
|
||||
export const ImageColor = z.object({
|
||||
hex: z.string(),
|
||||
isDark: z.boolean()
|
||||
})
|
||||
|
||||
export type ImageColor = z.infer<typeof ImageColor>;
|
||||
export type ImageDimensions = z.infer<typeof ImageDimensions>;
|
||||
|
||||
export const imagesTable = pgTable(
|
||||
"images",
|
||||
{
|
||||
...timestamps,
|
||||
type: ImageTypeEnum("type").notNull(),
|
||||
imageHash: varchar("image_hash", { length: 255 })
|
||||
.notNull(),
|
||||
baseGameID: varchar("base_game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
sourceUrl: text("source_url").notNull(),
|
||||
position: integer("position").notNull().default(0),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
dimensions: json("dimensions").$type<ImageDimensions>().notNull(),
|
||||
extractedColor: json("extracted_color").$type<ImageColor>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.imageHash, table.type, table.baseGameID, table.position]
|
||||
}),
|
||||
index("idx_images_type").on(table.type),
|
||||
index("idx_images_game_id").on(table.baseGameID),
|
||||
]
|
||||
)
|
||||
119
packages/core/src/images/index.ts
Normal file
119
packages/core/src/images/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { ImageColor, ImageDimensions, imagesTable } from "./images.sql";
|
||||
|
||||
export namespace Images {
|
||||
const Image = z.object({
|
||||
hash: z.string().openapi({
|
||||
description: "A unique cryptographic hash identifier for the image, used for deduplication and URL generation",
|
||||
example: Examples.CommonImg[0].hash
|
||||
}),
|
||||
averageColor: ImageColor.openapi({
|
||||
description: "The calculated dominant color of the image with light/dark classification, used for UI theming",
|
||||
example: Examples.CommonImg[0].averageColor
|
||||
}),
|
||||
dimensions: ImageDimensions.openapi({
|
||||
description: "The width and height dimensions of the image in pixels",
|
||||
example: Examples.CommonImg[0].dimensions
|
||||
}),
|
||||
fileSize: z.number().int().openapi({
|
||||
description: "The size of the image file in bytes, used for storage and bandwidth calculations",
|
||||
example: Examples.CommonImg[0].fileSize
|
||||
})
|
||||
})
|
||||
|
||||
export const Info = z.object({
|
||||
screenshots: Image.array().openapi({
|
||||
description: "In-game captured images showing actual gameplay, user interface, and key moments",
|
||||
example: Examples.Images.screenshots
|
||||
}),
|
||||
boxArts: Image.array().openapi({
|
||||
description: "Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails",
|
||||
example: Examples.Images.boxArts
|
||||
}),
|
||||
posters: Image.array().openapi({
|
||||
description: "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters",
|
||||
example: Examples.Images.posters
|
||||
}),
|
||||
heroArts: Image.array().openapi({
|
||||
description: "Horizontal promotional artwork optimized for header displays and banners",
|
||||
example: Examples.Images.heroArts
|
||||
}),
|
||||
superHeroArts: Image.array().openapi({
|
||||
description: "High-resolution, wide-format artwork designed for featured content and main entries",
|
||||
example: Examples.Images.superHeroArts
|
||||
}),
|
||||
backgrounds: Image.array().openapi({
|
||||
description: "Full-width background images optimized for page layouts and decorative purposes",
|
||||
example: Examples.Images.backgrounds
|
||||
}),
|
||||
logos: Image.array().openapi({
|
||||
description: "Official game logo artwork, typically with transparent backgrounds for flexible placement",
|
||||
example: Examples.Images.logos
|
||||
}),
|
||||
icons: Image.array().openapi({
|
||||
description: "Small-format identifiers used for application shortcuts and compact displays",
|
||||
example: Examples.Images.icons
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "Images",
|
||||
description: "Complete collection of game-related visual assets, including promotional materials, UI elements, and store assets",
|
||||
example: Examples.Images
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const InputInfo = createSelectSchema(imagesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) =>
|
||||
tx
|
||||
.insert(imagesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [imagesTable.imageHash, imagesTable.type, imagesTable.baseGameID, imagesTable.position],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof imagesTable.$inferSelect[],
|
||||
): z.infer<typeof Info> {
|
||||
return input
|
||||
.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.type.localeCompare(b.type);
|
||||
})
|
||||
.reduce<Record<`${typeof imagesTable.$inferSelect["type"]}s`, { hash: string; averageColor: ImageColor; dimensions: ImageDimensions; fileSize: number }[]>>((acc, img) => {
|
||||
const key = `${img.type}s` as `${typeof img.type}s`
|
||||
if (Array.isArray(acc[key])) {
|
||||
acc[key]!.push({
|
||||
hash: img.imageHash,
|
||||
averageColor: img.extractedColor,
|
||||
dimensions: img.dimensions,
|
||||
fileSize: img.fileSize
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, {
|
||||
screenshots: [],
|
||||
boxArts: [],
|
||||
superHeroArts: [],
|
||||
heroArts: [],
|
||||
posters: [],
|
||||
backgrounds: [],
|
||||
icons: [],
|
||||
logos: [],
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Game } from "../game";
|
||||
import { Actor } from "../actor";
|
||||
import { gamesTable } from "../game/game.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamLibraryTable } from "./library.sql";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { imagesTable } from "../images/images.sql";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { Actor } from "../actor";
|
||||
|
||||
export namespace Library {
|
||||
export const Info = createSelectSchema(steamLibraryTable)
|
||||
@@ -26,7 +27,7 @@ export namespace Library {
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.gameID, input.gameID),
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
eq(steamLibraryTable.ownerID, input.ownerID),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
@@ -37,12 +38,9 @@ export namespace Library {
|
||||
|
||||
await tx
|
||||
.insert(steamLibraryTable)
|
||||
.values({
|
||||
ownerID: input.ownerID,
|
||||
gameID: input.gameID
|
||||
})
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [steamLibraryTable.ownerID, steamLibraryTable.gameID],
|
||||
target: [steamLibraryTable.ownerID, steamLibraryTable.baseGameID],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
@@ -59,7 +57,7 @@ export namespace Library {
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, input.ownerID),
|
||||
eq(steamLibraryTable.gameID, input.gameID),
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -71,6 +69,7 @@ export namespace Library {
|
||||
.select({
|
||||
games: baseGamesTable,
|
||||
categories: categoriesTable,
|
||||
images: imagesTable
|
||||
})
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
@@ -81,7 +80,7 @@ export namespace Library {
|
||||
)
|
||||
.innerJoin(
|
||||
baseGamesTable,
|
||||
eq(baseGamesTable.id, steamLibraryTable.gameID),
|
||||
eq(baseGamesTable.id, steamLibraryTable.baseGameID),
|
||||
)
|
||||
.leftJoin(
|
||||
gamesTable,
|
||||
@@ -94,6 +93,20 @@ export namespace Library {
|
||||
eq(categoriesTable.type, gamesTable.categoryType),
|
||||
)
|
||||
)
|
||||
// Joining imagesTable 1-N with gamesTable multiplies rows; the subsequent Game.serialize has to uniqueBy to undo this.
|
||||
// For large libraries with many screenshots the Cartesian effect can significantly bloat the result and network payload.
|
||||
// One option is to aggregate the images in SQL before joining to keep exactly one row per game:
|
||||
// .leftJoin(
|
||||
// sql<typeof imagesTable.$inferSelect[]>`(SELECT * FROM images WHERE base_game_id = ${gamesTable.baseGameID} AND time_deleted IS NULL ORDER BY type, position)`.as("images"),
|
||||
// sql`TRUE`
|
||||
// )
|
||||
.leftJoin(
|
||||
imagesTable,
|
||||
and(
|
||||
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
||||
isNull(imagesTable.timeDeleted),
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => Game.serialize(rows))
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ export const steamLibraryTable = pgTable(
|
||||
"game_libraries",
|
||||
{
|
||||
...timestamps,
|
||||
gameID: varchar("game_id", { length: 255 })
|
||||
baseGameID: varchar("base_game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
@@ -21,7 +21,7 @@ export const steamLibraryTable = pgTable(
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.gameID, table.ownerID]
|
||||
columns: [table.baseGameID, table.ownerID]
|
||||
}),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerID),
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
json,
|
||||
table,
|
||||
@@ -82,9 +83,10 @@ const games = table("games")
|
||||
.columns({
|
||||
base_game_id: string(),
|
||||
category_slug: string(),
|
||||
type: enumeration<"tag" | "genre" | "publisher" | "developer">(),
|
||||
...timestamps
|
||||
})
|
||||
.primaryKey("category_slug", "base_game_id")
|
||||
.primaryKey("category_slug", "base_game_id", "type")
|
||||
|
||||
const base_games = table("base_games")
|
||||
.columns({
|
||||
@@ -96,7 +98,7 @@ const base_games = table("base_games")
|
||||
description: string(),
|
||||
primary_genre: string(),
|
||||
controller_support: string().optional(),
|
||||
compatibility: enumeration<"high" | "mid" | "low">(),
|
||||
compatibility: enumeration<"high" | "mid" | "low" | "unknown">(),
|
||||
score: number(),
|
||||
...timestamps
|
||||
})
|
||||
@@ -109,17 +111,29 @@ const categories = table("categories")
|
||||
name: string(),
|
||||
...timestamps
|
||||
})
|
||||
.primaryKey("slug")
|
||||
.primaryKey("slug", "type")
|
||||
|
||||
const game_libraries = table("game_libraries")
|
||||
.columns({
|
||||
game_id: string(),
|
||||
owner_id: string()
|
||||
})
|
||||
base_game_id: string(),
|
||||
owner_id: string(),
|
||||
...timestamps
|
||||
}).primaryKey("base_game_id", "owner_id")
|
||||
|
||||
const images = table("images")
|
||||
.columns({
|
||||
image_hash: string(),
|
||||
base_game_id: string(),
|
||||
type: enumeration<"heroArt" | "icon" | "logo" | "superHeroArt" | "poster" | "boxArt" | "screenshot" | "background">(),
|
||||
position: number(),
|
||||
dimensions: json<ImageDimensions>(),
|
||||
extracted_color: json<ImageColor>(),
|
||||
...timestamps
|
||||
}).primaryKey("image_hash", "type", "base_game_id", "position")
|
||||
|
||||
// Schema and Relationships
|
||||
export const schema = createSchema({
|
||||
tables: [users, steam_accounts, teams, members, friends_list, categories, base_games, games, game_libraries],
|
||||
tables: [users, steam_accounts, teams, members, friends_list, categories, base_games, games, game_libraries, images],
|
||||
relationships: [
|
||||
relationships(steam_accounts, (r) => ({
|
||||
user: r.one({
|
||||
@@ -215,22 +229,37 @@ export const schema = createSchema({
|
||||
libraries: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: game_libraries,
|
||||
destField: ["game_id"]
|
||||
destField: ["base_game_id"]
|
||||
}),
|
||||
images: r.many({
|
||||
sourceField: ["id"],
|
||||
destSchema: images,
|
||||
destField: ["base_game_id"]
|
||||
})
|
||||
})),
|
||||
relationships(categories, (r) => ({
|
||||
games: r.many({
|
||||
games_slug: r.many({
|
||||
sourceField: ["slug"],
|
||||
destSchema: games,
|
||||
destField: ["category_slug"]
|
||||
}),
|
||||
games_type: r.many({
|
||||
sourceField: ["type"],
|
||||
destSchema: games,
|
||||
destField: ["type"]
|
||||
})
|
||||
})),
|
||||
relationships(games, (r) => ({
|
||||
category: r.one({
|
||||
category_slug: r.one({
|
||||
sourceField: ["category_slug"],
|
||||
destSchema: categories,
|
||||
destField: ["slug"],
|
||||
}),
|
||||
category_type: r.one({
|
||||
sourceField: ["type"],
|
||||
destSchema: categories,
|
||||
destField: ["type"],
|
||||
}),
|
||||
base_game: r.one({
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
@@ -239,7 +268,7 @@ export const schema = createSchema({
|
||||
})),
|
||||
relationships(game_libraries, (r) => ({
|
||||
base_game: r.one({
|
||||
sourceField: ["game_id"],
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"],
|
||||
}),
|
||||
@@ -249,6 +278,13 @@ export const schema = createSchema({
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
relationships(images, (r) => ({
|
||||
base_game: r.one({
|
||||
sourceField: ["base_game_id"],
|
||||
destSchema: base_games,
|
||||
destField: ["id"],
|
||||
}),
|
||||
})),
|
||||
|
||||
],
|
||||
});
|
||||
@@ -307,6 +343,17 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
|
||||
]
|
||||
},
|
||||
},
|
||||
game_libraries: {
|
||||
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.related("friend", (s) => s.where("user_id", auth.sub)))),
|
||||
]
|
||||
}
|
||||
},
|
||||
//Games are publicly viewable
|
||||
games: {
|
||||
row: {
|
||||
@@ -323,14 +370,10 @@ export const permissions = definePermissions<Auth, Schema>(schema, () => {
|
||||
select: ANYONE_CAN
|
||||
}
|
||||
},
|
||||
game_libraries: {
|
||||
images: {
|
||||
row: {
|
||||
select: [
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.where("user_id", auth.sub)),
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("memberEntries", (f) => f.where("user_id", auth.sub))),
|
||||
(auth: Auth, q: ExpressionBuilder<Schema, 'game_libraries'>) => q.exists("owner", (u) => u.related("friends", (f) => f.related("friend", (s) => s.where("user_id", auth.sub)))),
|
||||
]
|
||||
select: ANYONE_CAN
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user