mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
fix: Move more directories
This commit is contained in:
316
cloud/packages/core/src/client/index.ts
Normal file
316
cloud/packages/core/src/client/index.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import type {
|
||||
Shot,
|
||||
AppInfo,
|
||||
ImageInfo,
|
||||
ImageType,
|
||||
SteamAccount,
|
||||
GameTagsResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
SteamOwnedGamesResponse,
|
||||
SteamPlayerBansResponse,
|
||||
SteamFriendsListResponse,
|
||||
SteamPlayerSummaryResponse,
|
||||
SteamStoreResponse,
|
||||
} from "./types";
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { Steam } from "./steam";
|
||||
import { Utils } from "./utils";
|
||||
import { ImageTypeEnum } from "../images/images.sql";
|
||||
|
||||
export namespace Client {
|
||||
export const getUserLibrary = fn(
|
||||
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(
|
||||
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(
|
||||
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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const info = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<SteamStoreResponse>(`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?key=${Resource.SteamApiKey.value}&input_json={"ids":[{"appid":"${appid}"}],"context":{"language":"english","country_code":"US","steam_realm":"1"},"data_request":{"include_assets":true,"include_release":true,"include_platforms":true,"include_all_purchase_options":true,"include_screenshots":true,"include_trailers":true,"include_ratings":true,"include_tag_count":"40","include_reviews":true,"include_basic_info":true,"include_supported_languages":true,"include_full_description":true,"include_included_items":true,"include_assets_without_overrides":true,"apply_user_filters":true,"include_links":true}}`),
|
||||
]);
|
||||
|
||||
const cmd = info[0].data[appid]
|
||||
const store = info[1].response.store_items[0]
|
||||
|
||||
if (!cmd) {
|
||||
throw new Error(`App data not found for appid: ${appid}`)
|
||||
}
|
||||
|
||||
if (!store || store.success !== 1) {
|
||||
throw new Error(`Could not get store information or appid: ${appid}`)
|
||||
}
|
||||
|
||||
const tags = store.tagids
|
||||
.map(id => Steam.tags[id.toString() as keyof typeof Steam.tags])
|
||||
.filter((name): name is string => typeof name === 'string')
|
||||
|
||||
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 getImageUrls = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [appData, details] = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<GameDetailsResponse>(
|
||||
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
|
||||
),
|
||||
]);
|
||||
|
||||
const game = appData.data[appid]?.common;
|
||||
if (!game) throw new Error('Game info missing');
|
||||
|
||||
// 2. Prepare URLs
|
||||
const screenshots = Utils.getScreenshotUrls(details.rgScreenshots || []);
|
||||
const assetUrls = Utils.getAssetUrls(game.library_assets_full, appid, game.header_image.english);
|
||||
const icon = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
|
||||
|
||||
return { screenshots, icon, ...assetUrls }
|
||||
}
|
||||
)
|
||||
|
||||
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(
|
||||
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
|
||||
? [{ url: shots[0].url, score: 0 }]
|
||||
: (await Utils.rankScreenshots(baselineBuffer, shots, {
|
||||
threshold: 0.08,
|
||||
}))
|
||||
|
||||
// Build url->rank map
|
||||
const rankMap = new Map<string, number>();
|
||||
scores.forEach((s, i) => rankMap.set(s.url, i));
|
||||
|
||||
// 5. Create tasks for all images
|
||||
const tasks: Array<Promise<ImageInfo>> = [];
|
||||
|
||||
// 5a. Screenshots and heroArt metadata (top 4)
|
||||
for (const { url, buffer } of shots) {
|
||||
const rank = rankMap.get(url);
|
||||
if (rank === undefined || rank >= 4) continue;
|
||||
const type: ImageType = rank === 0 ? 'heroArt' : 'screenshot';
|
||||
tasks.push(
|
||||
Utils.getImageMetadata(buffer).then(meta => ({ ...meta, sourceUrl: url, position: type == "screenshot" ? rank - 1 : rank, type } as ImageInfo))
|
||||
);
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(tasks);
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getHeroArt] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
// Await all and return
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<ImageInfo>).value)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Verifies a Steam OpenID response by sending a request back to Steam
|
||||
* with mode=check_authentication
|
||||
*/
|
||||
export async function verifyOpenIDResponse(params: URLSearchParams): Promise<string | null> {
|
||||
try {
|
||||
// Create a new URLSearchParams with all the original parameters
|
||||
const verificationParams = new URLSearchParams();
|
||||
|
||||
// Copy all parameters from the original request
|
||||
for (const [key, value] of params.entries()) {
|
||||
verificationParams.append(key, value);
|
||||
}
|
||||
|
||||
// Change mode to check_authentication for verification
|
||||
verificationParams.set('openid.mode', 'check_authentication');
|
||||
|
||||
// Send verification request to Steam
|
||||
const verificationResponse = await fetch('https://steamcommunity.com/openid/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: verificationParams.toString()
|
||||
});
|
||||
|
||||
const responseText = await verificationResponse.text();
|
||||
|
||||
// Check if verification was successful
|
||||
if (!responseText.includes('is_valid:true')) {
|
||||
console.error('OpenID verification failed: Invalid response from Steam', responseText);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract steamID from the claimed_id
|
||||
const claimedId = params.get('openid.claimed_id');
|
||||
if (!claimedId) {
|
||||
console.error('OpenID verification failed: Missing claimed_id');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the Steam ID from the claimed_id
|
||||
const steamID = claimedId.split('/').pop();
|
||||
if (!steamID || !/^\d+$/.test(steamID)) {
|
||||
console.error('OpenID verification failed: Invalid steamID format', steamID);
|
||||
return null;
|
||||
}
|
||||
|
||||
return steamID;
|
||||
} catch (error) {
|
||||
console.error('OpenID verification error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
544
cloud/packages/core/src/client/steam.ts
Normal file
544
cloud/packages/core/src/client/steam.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
export namespace Steam {
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/genres.json
|
||||
export const genres = {
|
||||
"1": "Action",
|
||||
"2": "Strategy",
|
||||
"3": "RPG",
|
||||
"4": "Casual",
|
||||
"9": "Racing",
|
||||
"18": "Sports",
|
||||
"23": "Indie",
|
||||
"25": "Adventure",
|
||||
"28": "Simulation",
|
||||
"29": "Massively Multiplayer",
|
||||
"37": "Free to Play",
|
||||
"50": "Accounting",
|
||||
"51": "Animation & Modeling",
|
||||
"52": "Audio Production",
|
||||
"53": "Design & Illustration",
|
||||
"54": "Education",
|
||||
"55": "Photo Editing",
|
||||
"56": "Software Training",
|
||||
"57": "Utilities",
|
||||
"58": "Video Production",
|
||||
"59": "Web Publishing",
|
||||
"60": "Game Development",
|
||||
"70": "Early Access",
|
||||
"71": "Sexual Content",
|
||||
"72": "Nudity",
|
||||
"73": "Violent",
|
||||
"74": "Gore",
|
||||
"80": "Movie",
|
||||
"81": "Documentary",
|
||||
"82": "Episodic",
|
||||
"83": "Short",
|
||||
"84": "Tutorial",
|
||||
"85": "360 Video"
|
||||
}
|
||||
|
||||
//Source: https://github.com/woctezuma/steam-api/blob/master/data/categories.json
|
||||
export const categories = {
|
||||
"1": "Multi-player",
|
||||
"2": "Single-player",
|
||||
"6": "Mods (require HL2)",
|
||||
"7": "Mods (require HL1)",
|
||||
"8": "Valve Anti-Cheat enabled",
|
||||
"9": "Co-op",
|
||||
"10": "Demos",
|
||||
"12": "HDR available",
|
||||
"13": "Captions available",
|
||||
"14": "Commentary available",
|
||||
"15": "Stats",
|
||||
"16": "Includes Source SDK",
|
||||
"17": "Includes level editor",
|
||||
"18": "Partial Controller Support",
|
||||
"19": "Mods",
|
||||
"20": "MMO",
|
||||
"21": "Downloadable Content",
|
||||
"22": "Steam Achievements",
|
||||
"23": "Steam Cloud",
|
||||
"24": "Shared/Split Screen",
|
||||
"25": "Steam Leaderboards",
|
||||
"27": "Cross-Platform Multiplayer",
|
||||
"28": "Full controller support",
|
||||
"29": "Steam Trading Cards",
|
||||
"30": "Steam Workshop",
|
||||
"31": "VR Support",
|
||||
"32": "Steam Turn Notifications",
|
||||
"33": "Native Steam Controller",
|
||||
"35": "In-App Purchases",
|
||||
"36": "Online PvP",
|
||||
"37": "Shared/Split Screen PvP",
|
||||
"38": "Online Co-op",
|
||||
"39": "Shared/Split Screen Co-op",
|
||||
"40": "SteamVR Collectibles",
|
||||
"41": "Remote Play on Phone",
|
||||
"42": "Remote Play on Tablet",
|
||||
"43": "Remote Play on TV",
|
||||
"44": "Remote Play Together",
|
||||
"45": "Cloud Gaming",
|
||||
"46": "Cloud Gaming (NVIDIA)",
|
||||
"47": "LAN PvP",
|
||||
"48": "LAN Co-op",
|
||||
"49": "PvP",
|
||||
"50": "Additional High-Quality Audio",
|
||||
"51": "Steam Workshop",
|
||||
"52": "Tracked Controller Support",
|
||||
"53": "VR Supported",
|
||||
"54": "VR Only"
|
||||
}
|
||||
|
||||
// Source: https://files.catbox.moe/96bty7.json
|
||||
export const tags = {
|
||||
"9": "Strategy",
|
||||
"19": "Action",
|
||||
"21": "Adventure",
|
||||
"84": "Design & Illustration",
|
||||
"87": "Utilities",
|
||||
"113": "Free to Play",
|
||||
"122": "RPG",
|
||||
"128": "Massively Multiplayer",
|
||||
"492": "Indie",
|
||||
"493": "Early Access",
|
||||
"597": "Casual",
|
||||
"599": "Simulation",
|
||||
"699": "Racing",
|
||||
"701": "Sports",
|
||||
"784": "Video Production",
|
||||
"809": "Photo Editing",
|
||||
"872": "Animation & Modeling",
|
||||
"1027": "Audio Production",
|
||||
"1036": "Education",
|
||||
"1038": "Web Publishing",
|
||||
"1445": "Software Training",
|
||||
"1616": "Trains",
|
||||
"1621": "Music",
|
||||
"1625": "Platformer",
|
||||
"1628": "Metroidvania",
|
||||
"1638": "Dog",
|
||||
"1643": "Building",
|
||||
"1644": "Driving",
|
||||
"1645": "Tower Defense",
|
||||
"1646": "Hack and Slash",
|
||||
"1647": "Western",
|
||||
"1649": "GameMaker",
|
||||
"1651": "Satire",
|
||||
"1654": "Relaxing",
|
||||
"1659": "Zombies",
|
||||
"1662": "Survival",
|
||||
"1663": "FPS",
|
||||
"1664": "Puzzle",
|
||||
"1665": "Match 3",
|
||||
"1666": "Card Game",
|
||||
"1667": "Horror",
|
||||
"1669": "Moddable",
|
||||
"1670": "4X",
|
||||
"1671": "Superhero",
|
||||
"1673": "Aliens",
|
||||
"1674": "Typing",
|
||||
"1676": "RTS",
|
||||
"1677": "Turn-Based",
|
||||
"1678": "War",
|
||||
"1680": "Heist",
|
||||
"1681": "Pirates",
|
||||
"1684": "Fantasy",
|
||||
"1685": "Co-op",
|
||||
"1687": "Stealth",
|
||||
"1688": "Ninja",
|
||||
"1693": "Classic",
|
||||
"1695": "Open World",
|
||||
"1697": "Third Person",
|
||||
"1698": "Point & Click",
|
||||
"1702": "Crafting",
|
||||
"1708": "Tactical",
|
||||
"1710": "Surreal",
|
||||
"1714": "Psychedelic",
|
||||
"1716": "Roguelike",
|
||||
"1717": "Hex Grid",
|
||||
"1718": "MOBA",
|
||||
"1719": "Comedy",
|
||||
"1720": "Dungeon Crawler",
|
||||
"1721": "Psychological Horror",
|
||||
"1723": "Action RTS",
|
||||
"1730": "Sokoban",
|
||||
"1732": "Voxel",
|
||||
"1733": "Unforgiving",
|
||||
"1734": "Fast-Paced",
|
||||
"1736": "LEGO",
|
||||
"1738": "Hidden Object",
|
||||
"1741": "Turn-Based Strategy",
|
||||
"1742": "Story Rich",
|
||||
"1743": "Fighting",
|
||||
"1746": "Basketball",
|
||||
"1751": "Comic Book",
|
||||
"1752": "Rhythm",
|
||||
"1753": "Skateboarding",
|
||||
"1754": "MMORPG",
|
||||
"1755": "Space",
|
||||
"1756": "Great Soundtrack",
|
||||
"1759": "Perma Death",
|
||||
"1770": "Board Game",
|
||||
"1773": "Arcade",
|
||||
"1774": "Shooter",
|
||||
"1775": "PvP",
|
||||
"1777": "Steampunk",
|
||||
"3796": "Based On A Novel",
|
||||
"3798": "Side Scroller",
|
||||
"3799": "Visual Novel",
|
||||
"3810": "Sandbox",
|
||||
"3813": "Real Time Tactics",
|
||||
"3814": "Third-Person Shooter",
|
||||
"3834": "Exploration",
|
||||
"3835": "Post-apocalyptic",
|
||||
"3839": "First-Person",
|
||||
"3841": "Local Co-Op",
|
||||
"3843": "Online Co-Op",
|
||||
"3854": "Lore-Rich",
|
||||
"3859": "Multiplayer",
|
||||
"3871": "2D",
|
||||
"3877": "Precision Platformer",
|
||||
"3878": "Competitive",
|
||||
"3916": "Old School",
|
||||
"3920": "Cooking",
|
||||
"3934": "Immersive",
|
||||
"3942": "Sci-fi",
|
||||
"3952": "Gothic",
|
||||
"3955": "Character Action Game",
|
||||
"3959": "Roguelite",
|
||||
"3964": "Pixel Graphics",
|
||||
"3965": "Epic",
|
||||
"3968": "Physics",
|
||||
"3978": "Survival Horror",
|
||||
"3987": "Historical",
|
||||
"3993": "Combat",
|
||||
"4004": "Retro",
|
||||
"4018": "Vampire",
|
||||
"4026": "Difficult",
|
||||
"4036": "Parkour",
|
||||
"4046": "Dragons",
|
||||
"4057": "Magic",
|
||||
"4064": "Thriller",
|
||||
"4085": "Anime",
|
||||
"4094": "Minimalist",
|
||||
"4102": "Combat Racing",
|
||||
"4106": "Action-Adventure",
|
||||
"4115": "Cyberpunk",
|
||||
"4136": "Funny",
|
||||
"4137": "Transhumanism",
|
||||
"4145": "Cinematic",
|
||||
"4150": "World War II",
|
||||
"4155": "Class-Based",
|
||||
"4158": "Beat 'em up",
|
||||
"4161": "Real-Time",
|
||||
"4166": "Atmospheric",
|
||||
"4168": "Military",
|
||||
"4172": "Medieval",
|
||||
"4175": "Realistic",
|
||||
"4182": "Singleplayer",
|
||||
"4184": "Chess",
|
||||
"4190": "Addictive",
|
||||
"4191": "3D",
|
||||
"4195": "Cartoony",
|
||||
"4202": "Trading",
|
||||
"4231": "Action RPG",
|
||||
"4234": "Short",
|
||||
"4236": "Loot",
|
||||
"4242": "Episodic",
|
||||
"4252": "Stylized",
|
||||
"4255": "Shoot 'Em Up",
|
||||
"4291": "Spaceships",
|
||||
"4295": "Futuristic",
|
||||
"4305": "Colorful",
|
||||
"4325": "Turn-Based Combat",
|
||||
"4328": "City Builder",
|
||||
"4342": "Dark",
|
||||
"4345": "Gore",
|
||||
"4364": "Grand Strategy",
|
||||
"4376": "Assassin",
|
||||
"4400": "Abstract",
|
||||
"4434": "JRPG",
|
||||
"4474": "CRPG",
|
||||
"4486": "Choose Your Own Adventure",
|
||||
"4508": "Co-op Campaign",
|
||||
"4520": "Farming",
|
||||
"4559": "Quick-Time Events",
|
||||
"4562": "Cartoon",
|
||||
"4598": "Alternate History",
|
||||
"4604": "Dark Fantasy",
|
||||
"4608": "Swordplay",
|
||||
"4637": "Top-Down Shooter",
|
||||
"4667": "Violent",
|
||||
"4684": "Wargame",
|
||||
"4695": "Economy",
|
||||
"4700": "Movie",
|
||||
"4711": "Replay Value",
|
||||
"4726": "Cute",
|
||||
"4736": "2D Fighter",
|
||||
"4747": "Character Customization",
|
||||
"4754": "Politics",
|
||||
"4758": "Twin Stick Shooter",
|
||||
"4777": "Spectacle fighter",
|
||||
"4791": "Top-Down",
|
||||
"4821": "Mechs",
|
||||
"4835": "6DOF",
|
||||
"4840": "4 Player Local",
|
||||
"4845": "Capitalism",
|
||||
"4853": "Political",
|
||||
"4878": "Parody",
|
||||
"4885": "Bullet Hell",
|
||||
"4947": "Romance",
|
||||
"4975": "2.5D",
|
||||
"4994": "Naval Combat",
|
||||
"5030": "Dystopian",
|
||||
"5055": "eSports",
|
||||
"5094": "Narration",
|
||||
"5125": "Procedural Generation",
|
||||
"5153": "Kickstarter",
|
||||
"5154": "Score Attack",
|
||||
"5160": "Dinosaurs",
|
||||
"5179": "Cold War",
|
||||
"5186": "Psychological",
|
||||
"5228": "Blood",
|
||||
"5230": "Sequel",
|
||||
"5300": "God Game",
|
||||
"5310": "Games Workshop",
|
||||
"5348": "Mod",
|
||||
"5350": "Family Friendly",
|
||||
"5363": "Destruction",
|
||||
"5372": "Conspiracy",
|
||||
"5379": "2D Platformer",
|
||||
"5382": "World War I",
|
||||
"5390": "Time Attack",
|
||||
"5395": "3D Platformer",
|
||||
"5407": "Benchmark",
|
||||
"5411": "Beautiful",
|
||||
"5432": "Programming",
|
||||
"5502": "Hacking",
|
||||
"5537": "Puzzle Platformer",
|
||||
"5547": "Arena Shooter",
|
||||
"5577": "RPGMaker",
|
||||
"5608": "Emotional",
|
||||
"5611": "Mature",
|
||||
"5613": "Detective",
|
||||
"5652": "Collectathon",
|
||||
"5673": "Modern",
|
||||
"5708": "Remake",
|
||||
"5711": "Team-Based",
|
||||
"5716": "Mystery",
|
||||
"5727": "Baseball",
|
||||
"5752": "Robots",
|
||||
"5765": "Gun Customization",
|
||||
"5794": "Science",
|
||||
"5796": "Bullet Time",
|
||||
"5851": "Isometric",
|
||||
"5900": "Walking Simulator",
|
||||
"5914": "Tennis",
|
||||
"5923": "Dark Humor",
|
||||
"5941": "Reboot",
|
||||
"5981": "Mining",
|
||||
"5984": "Drama",
|
||||
"6041": "Horses",
|
||||
"6052": "Noir",
|
||||
"6129": "Logic",
|
||||
"6214": "Birds",
|
||||
"6276": "Inventory Management",
|
||||
"6310": "Diplomacy",
|
||||
"6378": "Crime",
|
||||
"6426": "Choices Matter",
|
||||
"6506": "3D Fighter",
|
||||
"6621": "Pinball",
|
||||
"6625": "Time Manipulation",
|
||||
"6650": "Nudity",
|
||||
"6691": "1990's",
|
||||
"6702": "Mars",
|
||||
"6730": "PvE",
|
||||
"6815": "Hand-drawn",
|
||||
"6869": "Nonlinear",
|
||||
"6910": "Naval",
|
||||
"6915": "Martial Arts",
|
||||
"6948": "Rome",
|
||||
"6971": "Multiple Endings",
|
||||
"7038": "Golf",
|
||||
"7107": "Real-Time with Pause",
|
||||
"7108": "Party",
|
||||
"7113": "Crowdfunded",
|
||||
"7178": "Party Game",
|
||||
"7208": "Female Protagonist",
|
||||
"7250": "Linear",
|
||||
"7309": "Skiing",
|
||||
"7328": "Bowling",
|
||||
"7332": "Base Building",
|
||||
"7368": "Local Multiplayer",
|
||||
"7423": "Sniper",
|
||||
"7432": "Lovecraftian",
|
||||
"7478": "Illuminati",
|
||||
"7481": "Controller",
|
||||
"7556": "Dice",
|
||||
"7569": "Grid-Based Movement",
|
||||
"7622": "Offroad",
|
||||
"7702": "Narrative",
|
||||
"7743": "1980s",
|
||||
"7782": "Cult Classic",
|
||||
"7918": "Dwarf",
|
||||
"7926": "Artificial Intelligence",
|
||||
"7948": "Soundtrack",
|
||||
"8013": "Software",
|
||||
"8075": "TrackIR",
|
||||
"8093": "Minigames",
|
||||
"8122": "Level Editor",
|
||||
"8253": "Music-Based Procedural Generation",
|
||||
"8369": "Investigation",
|
||||
"8461": "Well-Written",
|
||||
"8666": "Runner",
|
||||
"8945": "Resource Management",
|
||||
"9130": "Hentai",
|
||||
"9157": "Underwater",
|
||||
"9204": "Immersive Sim",
|
||||
"9271": "Trading Card Game",
|
||||
"9541": "Demons",
|
||||
"9551": "Dating Sim",
|
||||
"9564": "Hunting",
|
||||
"9592": "Dynamic Narration",
|
||||
"9803": "Snow",
|
||||
"9994": "Experience",
|
||||
"10235": "Life Sim",
|
||||
"10383": "Transportation",
|
||||
"10397": "Memes",
|
||||
"10437": "Trivia",
|
||||
"10679": "Time Travel",
|
||||
"10695": "Party-Based RPG",
|
||||
"10808": "Supernatural",
|
||||
"10816": "Split Screen",
|
||||
"11014": "Interactive Fiction",
|
||||
"11095": "Boss Rush",
|
||||
"11104": "Vehicular Combat",
|
||||
"11123": "Mouse only",
|
||||
"11333": "Villain Protagonist",
|
||||
"11634": "Vikings",
|
||||
"12057": "Tutorial",
|
||||
"12095": "Sexual Content",
|
||||
"12190": "Boxing",
|
||||
"12286": "Warhammer 40K",
|
||||
"12472": "Management",
|
||||
"13070": "Solitaire",
|
||||
"13190": "America",
|
||||
"13276": "Tanks",
|
||||
"13382": "Archery",
|
||||
"13577": "Sailing",
|
||||
"13782": "Experimental",
|
||||
"13906": "Game Development",
|
||||
"14139": "Turn-Based Tactics",
|
||||
"14153": "Dungeons & Dragons",
|
||||
"14720": "Nostalgia",
|
||||
"14906": "Intentionally Awkward Controls",
|
||||
"15045": "Flight",
|
||||
"15172": "Conversation",
|
||||
"15277": "Philosophical",
|
||||
"15339": "Documentary",
|
||||
"15564": "Fishing",
|
||||
"15868": "Motocross",
|
||||
"15954": "Silent Protagonist",
|
||||
"16094": "Mythology",
|
||||
"16250": "Gambling",
|
||||
"16598": "Space Sim",
|
||||
"16689": "Time Management",
|
||||
"17015": "Werewolves",
|
||||
"17305": "Strategy RPG",
|
||||
"17337": "Lemmings",
|
||||
"17389": "Tabletop",
|
||||
"17770": "Asynchronous Multiplayer",
|
||||
"17894": "Cats",
|
||||
"17927": "Pool",
|
||||
"18594": "FMV",
|
||||
"19568": "Cycling",
|
||||
"19780": "Submarine",
|
||||
"19995": "Dark Comedy",
|
||||
"21006": "Underground",
|
||||
"21491": "Demo Available",
|
||||
"21725": "Tactical RPG",
|
||||
"21978": "VR",
|
||||
"22602": "Agriculture",
|
||||
"22955": "Mini Golf",
|
||||
"24003": "Word Game",
|
||||
"24904": "NSFW",
|
||||
"25085": "Touch-Friendly",
|
||||
"26921": "Political Sim",
|
||||
"27758": "Voice Control",
|
||||
"28444": "Snowboarding",
|
||||
"29363": "3D Vision",
|
||||
"29482": "Souls-like",
|
||||
"29855": "Ambient",
|
||||
"30358": "Nature",
|
||||
"30927": "Fox",
|
||||
"31275": "Text-Based",
|
||||
"31579": "Otome",
|
||||
"32322": "Deckbuilding",
|
||||
"33572": "Mahjong",
|
||||
"35079": "Job Simulator",
|
||||
"42089": "Jump Scare",
|
||||
"42329": "Coding",
|
||||
"42804": "Action Roguelike",
|
||||
"44868": "LGBTQ+",
|
||||
"47827": "Wrestling",
|
||||
"49213": "Rugby",
|
||||
"51306": "Foreign",
|
||||
"56690": "On-Rails Shooter",
|
||||
"61357": "Electronic Music",
|
||||
"65443": "Adult Content",
|
||||
"71389": "Spelling",
|
||||
"87918": "Farming Sim",
|
||||
"91114": "Shop Keeper",
|
||||
"92092": "Jet",
|
||||
"96359": "Skating",
|
||||
"97376": "Cozy",
|
||||
"102530": "Elf",
|
||||
"117648": "8-bit Music",
|
||||
"123332": "Bikes",
|
||||
"129761": "ATV",
|
||||
"143739": "Electronic",
|
||||
"150626": "Gaming",
|
||||
"158638": "Cricket",
|
||||
"176981": "Battle Royale",
|
||||
"180368": "Faith",
|
||||
"189941": "Instrumental Music",
|
||||
"198631": "Mystery Dungeon",
|
||||
"198913": "Motorbike",
|
||||
"220585": "Colony Sim",
|
||||
"233824": "Feature Film",
|
||||
"252854": "BMX",
|
||||
"255534": "Automation",
|
||||
"323922": "Musou",
|
||||
"324176": "Hockey",
|
||||
"337964": "Rock Music",
|
||||
"348922": "Steam Machine",
|
||||
"353880": "Looter Shooter",
|
||||
"363767": "Snooker",
|
||||
"379975": "Clicker",
|
||||
"454187": "Traditional Roguelike",
|
||||
"552282": "Wholesome",
|
||||
"603297": "Hardware",
|
||||
"615955": "Idler",
|
||||
"620519": "Hero Shooter",
|
||||
"745697": "Social Deduction",
|
||||
"769306": "Escape Room",
|
||||
"776177": "360 Video",
|
||||
"791774": "Card Battler",
|
||||
"847164": "Volleyball",
|
||||
"856791": "Asymmetric VR",
|
||||
"916648": "Creature Collector",
|
||||
"922563": "Roguevania",
|
||||
"1003823": "Profile Features Limited",
|
||||
"1023537": "Boomer Shooter",
|
||||
"1084988": "Auto Battler",
|
||||
"1091588": "Roguelike Deckbuilder",
|
||||
"1100686": "Outbreak Sim",
|
||||
"1100687": "Automobile Sim",
|
||||
"1100688": "Medical Sim",
|
||||
"1100689": "Open World Survival Craft",
|
||||
"1199779": "Extraction Shooter",
|
||||
"1220528": "Hobby Sim",
|
||||
"1254546": "Football (Soccer)",
|
||||
"1254552": "Football (American)",
|
||||
"1368160": "AI Content Disclosed",
|
||||
}
|
||||
}
|
||||
600
cloud/packages/core/src/client/types.ts
Normal file
600
cloud/packages/core/src/client/types.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
export interface SteamApp {
|
||||
/** Steam application ID */
|
||||
appid: number;
|
||||
|
||||
/** Array of Steam IDs that own this app */
|
||||
owner_steamids: string[];
|
||||
|
||||
/** Name of the game/application */
|
||||
name: string;
|
||||
|
||||
/** Filename of the game's capsule image */
|
||||
capsule_filename: string;
|
||||
|
||||
/** Hash value for the game's icon */
|
||||
img_icon_hash: string;
|
||||
|
||||
/** Reason code for exclusion (0 indicates no exclusion) */
|
||||
exclude_reason: number;
|
||||
|
||||
/** Unix timestamp when the app was acquired */
|
||||
rt_time_acquired: number;
|
||||
|
||||
/** Unix timestamp when the app was last played */
|
||||
rt_last_played: number;
|
||||
|
||||
/** Total playtime in seconds */
|
||||
rt_playtime: number;
|
||||
|
||||
/** Type identifier for the app (1 = game) */
|
||||
app_type: number;
|
||||
|
||||
/** Array of content descriptor IDs */
|
||||
content_descriptors?: number[];
|
||||
}
|
||||
|
||||
export interface SteamApiResponse {
|
||||
response: {
|
||||
apps: SteamApp[];
|
||||
owner_steamid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamAppDataResponse {
|
||||
data: Record<string, SteamAppEntry>;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SteamAppEntry {
|
||||
_change_number: number;
|
||||
_missing_token: boolean;
|
||||
_sha: string;
|
||||
_size: number;
|
||||
appid: string;
|
||||
common: CommonData;
|
||||
config: AppConfig;
|
||||
depots: AppDepots;
|
||||
extended: AppExtended;
|
||||
ufs: UFSData;
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
associations: Record<string, { name: string; type: string }>;
|
||||
category: Record<string, string>;
|
||||
clienticon: string;
|
||||
clienttga: string;
|
||||
community_hub_visible: string;
|
||||
community_visible_stats: string;
|
||||
content_descriptors: Record<string, string>;
|
||||
controller_support?: string;
|
||||
controllertagwizard: string;
|
||||
gameid: string;
|
||||
genres: Record<string, string>;
|
||||
header_image: Record<string, string>;
|
||||
icon: string;
|
||||
languages: Record<string, string>;
|
||||
library_assets: LibraryAssets;
|
||||
library_assets_full: LibraryAssetsFull;
|
||||
metacritic_fullurl: string;
|
||||
metacritic_name: string;
|
||||
metacritic_score: string;
|
||||
name: string;
|
||||
name_localized: Partial<Record<LanguageCode, string>>;
|
||||
osarch: string;
|
||||
osextended: string;
|
||||
oslist: string;
|
||||
primary_genre: string;
|
||||
releasestate: string;
|
||||
review_percentage: string;
|
||||
review_score: string;
|
||||
small_capsule: Record<string, string>;
|
||||
steam_deck_compatibility: SteamDeckCompatibility;
|
||||
steam_release_date: string;
|
||||
store_asset_mtime: string;
|
||||
store_tags: Record<string, string>;
|
||||
supported_languages: Record<
|
||||
string,
|
||||
{
|
||||
full_audio?: string;
|
||||
subtitles?: string;
|
||||
supported?: string;
|
||||
}
|
||||
>;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssets {
|
||||
library_capsule: string;
|
||||
library_header: string;
|
||||
library_hero: string;
|
||||
library_logo: string;
|
||||
logo_position: LogoPosition;
|
||||
}
|
||||
|
||||
export interface LogoPosition {
|
||||
height_pct: string;
|
||||
pinned_position: string;
|
||||
width_pct: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssetsFull {
|
||||
library_capsule: ImageSet;
|
||||
library_header: ImageSet;
|
||||
library_hero: ImageSet;
|
||||
library_logo: ImageSet & { logo_position: LogoPosition };
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ImageSet {
|
||||
image: Record<string, string>;
|
||||
image2x?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SteamDeckCompatibility {
|
||||
category: string;
|
||||
configuration: Record<string, string>;
|
||||
test_timestamp: string;
|
||||
tested_build_id: string;
|
||||
tests: Record<string, { display: string; token: string }>;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
installdir: string;
|
||||
launch: Record<
|
||||
string,
|
||||
{
|
||||
executable: string;
|
||||
type: string;
|
||||
arguments?: string;
|
||||
description?: string;
|
||||
description_loc?: Record<string, string>;
|
||||
config?: {
|
||||
betakey: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
steamcontrollertemplateindex: string;
|
||||
steamdecktouchscreen: string;
|
||||
}
|
||||
|
||||
export interface AppDepots {
|
||||
branches: AppDepotBranches;
|
||||
privatebranches: Record<string, AppDepotBranches>;
|
||||
[depotId: string]: DepotEntry
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
}
|
||||
|
||||
|
||||
export interface DepotEntry {
|
||||
manifests: {
|
||||
public: {
|
||||
download: string;
|
||||
gid: string;
|
||||
size: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppDepotBranches {
|
||||
[branchName: string]: {
|
||||
buildid: string;
|
||||
timeupdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppExtended {
|
||||
additional_dependencies: Array<{
|
||||
dest_os: string;
|
||||
h264: string;
|
||||
src_os: string;
|
||||
}>;
|
||||
developer: string;
|
||||
dlcavailableonstore: string;
|
||||
homepage: string;
|
||||
listofdlc: string;
|
||||
publisher: string;
|
||||
}
|
||||
|
||||
export interface UFSData {
|
||||
maxnumfiles: string;
|
||||
quota: string;
|
||||
savefiles: Array<{
|
||||
path: string;
|
||||
pattern: string;
|
||||
recursive: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type LanguageCode =
|
||||
| "english"
|
||||
| "french"
|
||||
| "german"
|
||||
| "italian"
|
||||
| "japanese"
|
||||
| "koreana"
|
||||
| "polish"
|
||||
| "russian"
|
||||
| "schinese"
|
||||
| "tchinese"
|
||||
| "brazilian"
|
||||
| "spanish";
|
||||
|
||||
export interface Screenshot {
|
||||
appid: number;
|
||||
id: number;
|
||||
filename: string;
|
||||
all_ages: string;
|
||||
normalized_name: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
strDisplayName: string;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
strReviewSummary: string;
|
||||
cReviews: number;
|
||||
cRecommendationsPositive: number;
|
||||
cRecommendationsNegative: number;
|
||||
nReviewScore: number;
|
||||
}
|
||||
|
||||
export interface GameDetailsResponse {
|
||||
strReleaseDate: string;
|
||||
strDescription: string;
|
||||
rgScreenshots: Screenshot[];
|
||||
rgCategories: Category[];
|
||||
strGenres?: string;
|
||||
strFullDescription: string;
|
||||
strMicroTrailerURL: string;
|
||||
ReviewSummary: ReviewSummary;
|
||||
}
|
||||
|
||||
// Define the TypeScript interfaces
|
||||
export interface Tag {
|
||||
tagid: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TagWithSlug {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface StoreTags {
|
||||
[key: string]: string; // Index signature for numeric string keys to tag ID strings
|
||||
}
|
||||
|
||||
|
||||
export interface GameTagsResponse {
|
||||
tags: Tag[];
|
||||
success: number;
|
||||
rwgrsn: number;
|
||||
}
|
||||
|
||||
export type GenreType = {
|
||||
type: 'genre';
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
slug: string;
|
||||
images: {
|
||||
logo: string;
|
||||
backdrop: string;
|
||||
poster: string;
|
||||
banner: string;
|
||||
screenshots: string[];
|
||||
icon: string;
|
||||
}
|
||||
links: string[] | null;
|
||||
score: number;
|
||||
id: string;
|
||||
releaseDate: Date;
|
||||
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" }>;
|
||||
}
|
||||
|
||||
export type ImageType =
|
||||
| 'screenshot'
|
||||
| 'boxArt'
|
||||
| 'banner'
|
||||
| 'backdrop'
|
||||
| 'icon'
|
||||
| 'logo'
|
||||
| 'poster'
|
||||
| 'heroArt';
|
||||
|
||||
export interface ImageInfo {
|
||||
type: ImageType;
|
||||
position: number;
|
||||
hash: string;
|
||||
sourceUrl: string | null;
|
||||
format?: string;
|
||||
averageColor: { hex: string; isDark: boolean };
|
||||
dimensions: { width: number; height: number };
|
||||
fileSize: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface CompareOpts {
|
||||
/** Pixelmatch color threshold (0–1). Default: 0.1 */
|
||||
threshold?: number;
|
||||
/** If true, return an image buffer of the diff map. Default: false */
|
||||
diffOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
diffRatio: number;
|
||||
/** Present only if `diffOutput: true` */
|
||||
diffBuffer?: Buffer;
|
||||
}
|
||||
|
||||
export interface Shot {
|
||||
url: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface RankedShot {
|
||||
url: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
524
cloud/packages/core/src/client/utils.ts
Normal file
524
cloud/packages/core/src/client/utils.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import type {
|
||||
Tag,
|
||||
StoreTags,
|
||||
AppDepots,
|
||||
GenreType,
|
||||
LibraryAssetsFull,
|
||||
DepotEntry,
|
||||
CompareOpts,
|
||||
CompareResult,
|
||||
RankedShot,
|
||||
Shot,
|
||||
ProfileInfo,
|
||||
} from "./types";
|
||||
import crypto from 'crypto';
|
||||
import pLimit from 'p-limit';
|
||||
import { PNG } from 'pngjs';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
import { Agent as HttpsAgent } from 'https';
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import sharp, { type Metadata } from 'sharp';
|
||||
import AbortController from 'abort-controller';
|
||||
import fetch, { RequestInit } from 'node-fetch';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
// --- Configuration ---
|
||||
const httpAgent = new HttpAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const httpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const downloadCache = new LRUCache<string, Buffer>({
|
||||
max: 100,
|
||||
ttl: 1000 * 60 * 30, // 30-minute expiry
|
||||
allowStale: false,
|
||||
});
|
||||
const downloadLimit = pLimit(10); // max concurrent downloads
|
||||
const compareCache = new LRUCache<string, CompareResult>({
|
||||
max: 50,
|
||||
ttl: 1000 * 60 * 10, // 10-minute expiry
|
||||
});
|
||||
|
||||
export namespace Utils {
|
||||
export async function fetchBuffer(url: string, retries = 3): Promise<Buffer> {
|
||||
if (downloadCache.has(url)) {
|
||||
return downloadCache.get(url)!;
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), 15_000);
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent
|
||||
} as RequestInit);
|
||||
clearTimeout(id);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
downloadCache.set(url, buf);
|
||||
return buf;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
if (attempt < retries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
export async function getImageMetadata(buffer: Buffer) {
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
const { width, height, format, size: fileSize } = await sharp(buffer).metadata();
|
||||
if (!width || !height) throw new Error('Invalid dimensions');
|
||||
|
||||
const slice = await sharp(buffer)
|
||||
.resize({ width: Math.min(width, 256) }) // cheap shrink
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
const pixelArray = new Uint8Array(slice.buffer);
|
||||
const { hex, isDark } = fac.prepareResult(fac.getColorFromArray4(pixelArray, { mode: "precision" }));
|
||||
|
||||
return { hash, format, averageColor: { hex, isDark }, dimensions: { width, height }, fileSize, buffer };
|
||||
}
|
||||
|
||||
// --- Optimized Box Art creation ---
|
||||
export async function createBoxArtBuffer(
|
||||
logoUrl: string,
|
||||
backgroundUrl: string,
|
||||
logoPercent = 0.9
|
||||
): Promise<Buffer> {
|
||||
const [bgBuf, logoBuf] = await Promise.all([
|
||||
downloadLimit(() =>
|
||||
fetchBuffer(backgroundUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download hero image from ${backgroundUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: hero image unavailable`);
|
||||
}),
|
||||
),
|
||||
downloadLimit(() => fetchBuffer(logoUrl)
|
||||
.catch(error => {
|
||||
console.error(`Failed to download logo image from ${logoUrl}:`, error);
|
||||
throw new Error(`Failed to create box art: logo image unavailable`);
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const bgImage = sharp(bgBuf);
|
||||
const meta = await bgImage.metadata();
|
||||
if (!meta.width || !meta.height) throw new Error('Invalid background dimensions');
|
||||
const size = Math.min(meta.width, meta.height);
|
||||
const left = Math.floor((meta.width - size) / 2);
|
||||
const top = Math.floor((meta.height - size) / 2);
|
||||
const squareBg = bgImage.extract({ left, top, width: size, height: size });
|
||||
|
||||
// Resize logo
|
||||
const logoTarget = Math.floor(size * logoPercent);
|
||||
const logoResized = await sharp(logoBuf).resize({ width: logoTarget }).toBuffer();
|
||||
const logoMeta = await sharp(logoResized).metadata();
|
||||
if (!logoMeta.width || !logoMeta.height) throw new Error('Invalid logo dimensions');
|
||||
const logoLeft = Math.floor((size - logoMeta.width) / 2);
|
||||
const logoTop = Math.floor((size - logoMeta.height) / 2);
|
||||
|
||||
return await squareBg
|
||||
.composite([{ input: logoResized, left: logoLeft, top: logoTop }])
|
||||
.jpeg({ quality: 100 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from the given URL, with Steam-like headers
|
||||
*/
|
||||
export async function fetchApi<T>(url: string, retries = 3): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Steam 1291812 / iPhone",
|
||||
"Accept-Language": "en-us",
|
||||
},
|
||||
} as RequestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
// Only retry on network errors or 5xx status codes
|
||||
if (error.message.includes('API error: 5') || !error.message.includes('API error')) {
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a name
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a candidate screenshot against a UI-free baseline to find how much UI/HUD remains.
|
||||
*
|
||||
* @param baselineBuffer - PNG/JPEG buffer of the clean background.
|
||||
* @param candidateBuffer - PNG/JPEG buffer of the screenshot to test.
|
||||
* @param opts - Options.
|
||||
* @returns Promise resolving to diff ratio (and optional diff image).
|
||||
*/
|
||||
export async function compareWithBaseline(
|
||||
baselineBuffer: Buffer,
|
||||
candidateBuffer: Buffer,
|
||||
opts: CompareOpts = {}
|
||||
): Promise<CompareResult> {
|
||||
// Generate cache key from buffer hashes
|
||||
const baseHash = crypto.createHash('md5').update(baselineBuffer).digest('hex');
|
||||
const candHash = crypto.createHash('md5').update(candidateBuffer).digest('hex');
|
||||
const optsKey = JSON.stringify(opts);
|
||||
const cacheKey = `${baseHash}:${candHash}:${optsKey}`;
|
||||
|
||||
// Check cache
|
||||
if (compareCache.has(cacheKey)) {
|
||||
return compareCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const { threshold = 0.1, diffOutput = false } = opts;
|
||||
|
||||
// Get dimensions of baseline
|
||||
const baseMeta: Metadata = await sharp(baselineBuffer).metadata();
|
||||
if (!baseMeta.width || !baseMeta.height) {
|
||||
throw new Error('Invalid baseline dimensions');
|
||||
}
|
||||
|
||||
// Produce PNG buffers of same size
|
||||
const [pngBaseBuf, pngCandBuf] = await Promise.all([
|
||||
sharp(baselineBuffer).png().toBuffer(),
|
||||
sharp(candidateBuffer)
|
||||
.resize(baseMeta.width, baseMeta.height)
|
||||
.png()
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
const imgBase = PNG.sync.read(pngBaseBuf);
|
||||
const imgCand = PNG.sync.read(pngCandBuf);
|
||||
const diffImg = new PNG({ width: baseMeta.width, height: baseMeta.height });
|
||||
|
||||
const numDiff = pixelmatch(
|
||||
imgBase.data,
|
||||
imgCand.data,
|
||||
diffImg.data,
|
||||
baseMeta.width,
|
||||
baseMeta.height,
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
const total = baseMeta.width * baseMeta.height;
|
||||
const diffRatio = numDiff / total;
|
||||
|
||||
const result: CompareResult = { diffRatio };
|
||||
if (diffOutput) {
|
||||
result.diffBuffer = PNG.sync.write(diffImg);
|
||||
}
|
||||
|
||||
compareCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a baseline buffer and an array of screenshots, returns them sorted
|
||||
* ascending by diffRatio (least UI first).
|
||||
*/
|
||||
export async function rankScreenshots(
|
||||
baselineBuffer: Buffer,
|
||||
shots: Shot[],
|
||||
opts: CompareOpts = {}
|
||||
): Promise<RankedShot[]> {
|
||||
// Process up to 5 comparisons in parallel
|
||||
const compareLimit = pLimit(5);
|
||||
|
||||
// Run all comparisons with limited concurrency
|
||||
const results = await Promise.all(
|
||||
shots.map(shot =>
|
||||
compareLimit(async () => {
|
||||
const { diffRatio } = await compareWithBaseline(
|
||||
baselineBuffer,
|
||||
shot.buffer,
|
||||
opts
|
||||
);
|
||||
return { url: shot.url, score: diffRatio };
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return results.sort((a, b) => a.score - b.score);
|
||||
}
|
||||
|
||||
// --- Helpers for URLs ---
|
||||
export function getScreenshotUrls(screenshots: { appid: number; filename: string }[]): string[] {
|
||||
return screenshots.map(s => `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${s.appid}/${s.filename}`);
|
||||
}
|
||||
|
||||
export function getAssetUrls(assets: LibraryAssetsFull, appid: number | string, header: string) {
|
||||
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
|
||||
return {
|
||||
logo: `${base}/${assets.library_logo?.image2x?.english || assets.library_logo?.image?.english}`,
|
||||
backdrop: `${base}/${assets.library_hero?.image2x?.english || assets.library_hero?.image?.english}`,
|
||||
poster: `${base}/${assets.library_capsule?.image2x?.english || assets.library_capsule?.image?.english}`,
|
||||
banner: `${base}/${assets.library_header?.image2x?.english || assets.library_header?.image?.english || header}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a 0–5 score from positive/negative votes using a Wilson score confidence interval.
|
||||
* This formula adjusts the raw ratio based on the total number of votes to account for
|
||||
* statistical confidence. With few votes, the score regresses toward 2.5 (neutral).
|
||||
*
|
||||
* Compute a 0–5 score from positive/negative votes
|
||||
*/
|
||||
export function getRating(positive: number, negative: number): number {
|
||||
const total = positive + negative;
|
||||
if (!total) return 0;
|
||||
const avg = positive / total;
|
||||
// Apply Wilson score confidence adjustment and scale to 0-5 range
|
||||
const score = avg - (avg - 0.5) * Math.pow(2, -Math.log10(total + 1));
|
||||
return Math.round(score * 5 * 10) / 10;
|
||||
}
|
||||
|
||||
export function getAssociationsByTypeWithSlug<
|
||||
T extends "developer" | "publisher"
|
||||
>(
|
||||
associations: Record<string, { name: string; type: string }>,
|
||||
type: T
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
return Object.values(associations)
|
||||
.filter((a) => a.type === type)
|
||||
.map((a) => ({ name: a.name.trim(), slug: createSlug(a.name.trim()), type }));
|
||||
}
|
||||
|
||||
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
|
||||
switch (type) {
|
||||
case "1":
|
||||
return "high";
|
||||
case "2":
|
||||
return "mid";
|
||||
case "3":
|
||||
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"
|
||||
>(
|
||||
available: Tag[],
|
||||
storeTags: StoreTags,
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
const tagMap = new Map<number, Tag>(available.map((t) => [t.tagid, t]));
|
||||
const result: Array<{ name: string; slug: string; type: T }> = Object.values(storeTags)
|
||||
.map((id) => tagMap.get(Number(id)))
|
||||
.filter((t): t is Tag => Boolean(t))
|
||||
.map((t) => ({ name: t.name.trim(), slug: createSlug(t.name), type: 'tag' as T }));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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')
|
||||
*/
|
||||
export function createTag<
|
||||
T extends string = 'tag'
|
||||
>(
|
||||
name: string,
|
||||
type?: T
|
||||
): { name: string; slug: string; type: T } {
|
||||
const tagType = (type ?? 'tag') as T;
|
||||
return {
|
||||
name: name.trim(),
|
||||
slug: createSlug(name),
|
||||
type: tagType,
|
||||
};
|
||||
}
|
||||
|
||||
export function capitalise(name: string) {
|
||||
return name
|
||||
.charAt(0) // first character
|
||||
.toUpperCase() // make it uppercase
|
||||
+ name
|
||||
.slice(1) // rest of the string
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
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) {
|
||||
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 (!isDepotEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dl = Number(entry.manifests.public.download);
|
||||
const sz = Number(entry.manifests.public.size);
|
||||
if (!Number.isFinite(dl) || !Number.isFinite(sz)) {
|
||||
console.warn(`[getPublicDepotSizes] non-numeric size for depot ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
download += dl;
|
||||
size += sz;
|
||||
}
|
||||
|
||||
return { downloadSize: download, sizeOnDisk: size };
|
||||
}
|
||||
|
||||
export function parseGenres(str: string): GenreType[] {
|
||||
return str.split(',')
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean)
|
||||
.map((g) => ({ type: 'genre', name: g.trim(), slug: createSlug(g) }));
|
||||
}
|
||||
|
||||
export function getPrimaryGenre(
|
||||
genres: GenreType[],
|
||||
map: Record<string, string>,
|
||||
primaryId: string
|
||||
): string | null {
|
||||
const idx = Object.keys(map).find((k) => map[k] === primaryId);
|
||||
return idx !== undefined ? genres[Number(idx)]?.name : null;
|
||||
}
|
||||
|
||||
export function cleanDescription(input: string): string {
|
||||
|
||||
const cleaned = sanitizeHtml(input, {
|
||||
allowedTags: [], // no tags allowed
|
||||
allowedAttributes: {}, // no attributes anywhere
|
||||
textFilter: (text) => text.replace(/\s+/g, ' '), // collapse runs of whitespace
|
||||
});
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user