feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
This commit is contained in:
591
server/src/services/game-matching.service.ts
Normal file
591
server/src/services/game-matching.service.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
import { getRawgApiKey } from '../config/variables';
|
||||
import { getDataSource } from '../db/database';
|
||||
import { GameMatchMissEntity } from '../entities';
|
||||
|
||||
export interface MatchedGame {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl?: string;
|
||||
store?: GameStoreLink;
|
||||
processName: string;
|
||||
}
|
||||
|
||||
export interface GameStoreLink {
|
||||
id?: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
domain?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
game: Omit<MatchedGame, 'processName'> | null;
|
||||
}
|
||||
|
||||
interface RawgSearchResponse {
|
||||
results?: RawgGameResult[];
|
||||
}
|
||||
|
||||
interface RawgGameResult {
|
||||
id?: number;
|
||||
name?: string;
|
||||
background_image?: string | null;
|
||||
slug?: string;
|
||||
stores?: RawgStoreEntry[] | null;
|
||||
}
|
||||
|
||||
interface RawgStoreEntry {
|
||||
url?: string | null;
|
||||
store?: RawgStore | null;
|
||||
}
|
||||
|
||||
interface RawgStore {
|
||||
id?: number;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
domain?: string | null;
|
||||
}
|
||||
|
||||
interface CandidateProcess {
|
||||
processName: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface GameMatchResult {
|
||||
games: MatchedGame[];
|
||||
rateLimited?: boolean;
|
||||
}
|
||||
|
||||
interface RawgLookupBudget {
|
||||
used: number;
|
||||
windowStartedAt: number;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
|
||||
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
|
||||
const MAX_INCOMING_PROCESSES = 256;
|
||||
const MAX_CANDIDATE_PROCESSES = 24;
|
||||
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
|
||||
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
|
||||
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
|
||||
const MIN_SEARCH_QUERY_LENGTH = 4;
|
||||
const IGNORED_PROCESS_NAMES = new Set([
|
||||
'agent',
|
||||
'bash',
|
||||
'baloorunner',
|
||||
'chrome',
|
||||
'code',
|
||||
'conhost',
|
||||
'cursor',
|
||||
'csrss',
|
||||
'dbus-daemon',
|
||||
'discord',
|
||||
'dwm',
|
||||
'electron',
|
||||
'explorer',
|
||||
'firefox',
|
||||
'gameoverlayui',
|
||||
'gamemoded',
|
||||
'gamescopereaper',
|
||||
'gnome-shell',
|
||||
'init',
|
||||
'kernel_task',
|
||||
'metoyou',
|
||||
'nvidia-settings',
|
||||
'node',
|
||||
'npm',
|
||||
'obs',
|
||||
'powershell',
|
||||
'pulseaudio',
|
||||
'services',
|
||||
'steam',
|
||||
'steamwebhelper',
|
||||
'system',
|
||||
'systemd',
|
||||
'taskhostw',
|
||||
'wininit',
|
||||
'winlogon',
|
||||
'xorg'
|
||||
]);
|
||||
const IGNORED_PROCESS_PATTERNS = [
|
||||
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
|
||||
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
|
||||
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
|
||||
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
|
||||
/^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
|
||||
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
|
||||
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
|
||||
];
|
||||
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
|
||||
steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
|
||||
'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
|
||||
gog: (query) => `https://www.gog.com/en/games?query=${query}`,
|
||||
itch: (query) => `https://itch.io/search?q=${query}`,
|
||||
'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
|
||||
'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
|
||||
nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
|
||||
'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
|
||||
'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
|
||||
};
|
||||
const STORE_SEARCH_ALIASES = new Map<string, string>([
|
||||
['steam', 'steam'],
|
||||
['store.steampowered.com', 'steam'],
|
||||
['epic-games', 'epic-games'],
|
||||
['store.epicgames.com', 'epic-games'],
|
||||
['gog', 'gog'],
|
||||
['www.gog.com', 'gog'],
|
||||
['gog.com', 'gog'],
|
||||
['itch', 'itch'],
|
||||
['itch.io', 'itch'],
|
||||
['xbox-store', 'xbox-store'],
|
||||
['www.xbox.com', 'xbox-store'],
|
||||
['xbox.com', 'xbox-store'],
|
||||
['playstation-store', 'playstation-store'],
|
||||
['store.playstation.com', 'playstation-store'],
|
||||
['nintendo', 'nintendo'],
|
||||
['www.nintendo.com', 'nintendo'],
|
||||
['nintendo.com', 'nintendo'],
|
||||
['apple-appstore', 'apple-appstore'],
|
||||
['apps.apple.com', 'apple-appstore'],
|
||||
['google-play', 'google-play'],
|
||||
['play.google.com', 'google-play']
|
||||
]);
|
||||
const STORE_PRIORITY = new Map<string, number>([
|
||||
['steam', 0],
|
||||
['gog', 10],
|
||||
['epic-games', 20],
|
||||
['itch', 30],
|
||||
['xbox-store', 80],
|
||||
['playstation-store', 90]
|
||||
]);
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
|
||||
|
||||
export async function matchRunningGames(
|
||||
processNames: unknown,
|
||||
requester: unknown = 'anonymous'
|
||||
): Promise<GameMatchResult> {
|
||||
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
|
||||
const matches: MatchedGame[] = [];
|
||||
const seenGameIds = new Set<string>();
|
||||
const requesterKey = normalizeRequesterKey(requester);
|
||||
const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));
|
||||
|
||||
let uncachedLookups = 0;
|
||||
let rateLimited = false;
|
||||
|
||||
for (const { processName } of candidates) {
|
||||
const cacheKey = normalizeCacheKey(processName);
|
||||
const cached = getCachedGame(cacheKey);
|
||||
|
||||
if (cached !== undefined) {
|
||||
appendMatch(matches, seenGameIds, processName, cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (persistedMisses.has(cacheKey)) {
|
||||
setCachedGame(cacheKey, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
|
||||
rateLimited = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!tryConsumeRawgLookup(requesterKey)) {
|
||||
rateLimited = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
uncachedLookups += 1;
|
||||
|
||||
const game = await resolveRawgGame(processName);
|
||||
|
||||
setCachedGame(cacheKey, game);
|
||||
|
||||
if (!game) {
|
||||
await rememberPersistedMiss(cacheKey, processName);
|
||||
}
|
||||
|
||||
appendMatch(matches, seenGameIds, processName, game);
|
||||
}
|
||||
|
||||
return {
|
||||
games: matches,
|
||||
rateLimited: rateLimited || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProcessList(value: unknown): CandidateProcess[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processes = new Map<string, CandidateProcess>();
|
||||
|
||||
for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
|
||||
const processName = normalizeProcessName(entry);
|
||||
|
||||
if (processName) {
|
||||
const cacheKey = normalizeCacheKey(processName);
|
||||
|
||||
if (!processes.has(cacheKey)) {
|
||||
processes.set(cacheKey, {
|
||||
processName,
|
||||
score: scoreCandidateProcess(String(entry), processName)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(processes.values())
|
||||
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
|
||||
}
|
||||
|
||||
function normalizeProcessName(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/\.exe$/i, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const cacheKey = normalizeCacheKey(normalized);
|
||||
|
||||
if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function shouldIgnoreProcessName(cacheKey: string): boolean {
|
||||
return IGNORED_PROCESS_NAMES.has(cacheKey)
|
||||
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
|
||||
}
|
||||
|
||||
function normalizeRequesterKey(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return 'anonymous';
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
return normalized || 'anonymous';
|
||||
}
|
||||
|
||||
function tryConsumeRawgLookup(requesterKey: string): boolean {
|
||||
const now = Date.now();
|
||||
const existing = rawgLookupBudgets.get(requesterKey);
|
||||
|
||||
if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
|
||||
rawgLookupBudgets.set(requesterKey, {
|
||||
used: 1,
|
||||
windowStartedAt: now
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
|
||||
return false;
|
||||
}
|
||||
|
||||
existing.used += 1;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function scoreCandidateProcess(rawValue: string, processName: string): number {
|
||||
let score = 0;
|
||||
|
||||
if (/\.exe$/i.test(rawValue.trim())) {
|
||||
score += 12;
|
||||
}
|
||||
|
||||
if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
if (/\d/.test(processName)) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (processName.length >= 5 && processName.length <= 32) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if (processName.includes(' ')) {
|
||||
score -= 2;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function normalizeCacheKey(value: string): string {
|
||||
return value.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
|
||||
const cached = cache.get(cacheKey);
|
||||
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
cache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return cached.game;
|
||||
}
|
||||
|
||||
function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
|
||||
cache.set(cacheKey, {
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
game
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
|
||||
const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));
|
||||
|
||||
if (cacheKeys.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = getDataSource().getRepository(GameMatchMissEntity);
|
||||
const now = Date.now();
|
||||
|
||||
await repository.createQueryBuilder()
|
||||
.delete()
|
||||
.where('expiresAt <= :now', { now })
|
||||
.execute();
|
||||
|
||||
const rows = await repository.createQueryBuilder('miss')
|
||||
.select('miss.processKey')
|
||||
.where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
|
||||
.andWhere('miss.expiresAt > :now', { now })
|
||||
.getMany();
|
||||
|
||||
return new Set(rows.map((row) => row.processKey));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
await getDataSource().getRepository(GameMatchMissEntity)
|
||||
.save({
|
||||
processKey: cacheKey,
|
||||
processName,
|
||||
missedAt: now,
|
||||
expiresAt: now + PERSISTED_MISS_TTL_MS
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
|
||||
const apiKey = getRawgApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = buildSearchQuery(processName);
|
||||
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(RAWG_SEARCH_URL);
|
||||
|
||||
url.searchParams.set('key', apiKey);
|
||||
url.searchParams.set('search', query);
|
||||
url.searchParams.set('search_precise', 'true');
|
||||
url.searchParams.set('exclude_additions', 'true');
|
||||
url.searchParams.set('page_size', '1');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await response.json() as RawgSearchResponse;
|
||||
const result = body.results?.[0];
|
||||
|
||||
if (!isAcceptableRawgMatch(query, result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(result.id),
|
||||
name: result.name.trim(),
|
||||
iconUrl: result.background_image || undefined,
|
||||
store: selectPreferredStore(result, result.name.trim())
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
|
||||
const stores = Array.isArray(result.stores) ? result.stores : [];
|
||||
const usableStores = stores
|
||||
.map((entry) => buildStoreLink(entry, gameName))
|
||||
.filter((store): store is GameStoreLink => !!store);
|
||||
|
||||
return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
|
||||
}
|
||||
|
||||
function getStorePriority(store: GameStoreLink): number {
|
||||
const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
|
||||
?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
|
||||
?? store.name.trim().toLowerCase();
|
||||
|
||||
return STORE_PRIORITY.get(storeKey) ?? 50;
|
||||
}
|
||||
|
||||
function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
|
||||
const store = entry.store;
|
||||
|
||||
if (!store || typeof store.name !== 'string' || !store.name.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const slug = typeof store.slug === 'string' && store.slug.trim()
|
||||
? store.slug.trim().toLowerCase()
|
||||
: undefined;
|
||||
const domain = typeof store.domain === 'string' && store.domain.trim()
|
||||
? store.domain.trim()
|
||||
.replace(/^https?:\/\//i, '')
|
||||
.replace(/\/$/, '')
|
||||
: undefined;
|
||||
const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);
|
||||
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: typeof store.id === 'number' ? String(store.id) : undefined,
|
||||
name: store.name.trim(),
|
||||
slug,
|
||||
domain,
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExternalUrl(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
|
||||
? trimmed
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
|
||||
const query = encodeURIComponent(gameName);
|
||||
const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
|
||||
const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;
|
||||
|
||||
return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
|
||||
}
|
||||
|
||||
function buildSearchQuery(processName: string): string {
|
||||
const query = processName
|
||||
.replace(/\.exe$/i, '')
|
||||
.replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
|
||||
}
|
||||
|
||||
function isAcceptableRawgMatch(
|
||||
query: string,
|
||||
result: RawgGameResult | undefined
|
||||
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
|
||||
if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const queryKey = normalizeComparableText(query);
|
||||
const nameKey = normalizeComparableText(result.name);
|
||||
const slugKey = normalizeComparableText(result.slug ?? '');
|
||||
const queryTokens = tokenizeComparableText(queryKey);
|
||||
const nameTokens = tokenizeComparableText(nameKey);
|
||||
const slugTokens = tokenizeComparableText(slugKey);
|
||||
|
||||
if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (queryKey === nameKey || queryKey === slugKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (queryTokens.length === 1) {
|
||||
const [queryToken] = queryTokens;
|
||||
|
||||
return queryToken.length >= 5
|
||||
&& (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
|
||||
}
|
||||
|
||||
return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
|
||||
}
|
||||
|
||||
function normalizeComparableText(value: string): string {
|
||||
return value.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function tokenizeComparableText(value: string): string[] {
|
||||
return value.split(' ')
|
||||
.filter((token) => token.length >= 2);
|
||||
}
|
||||
|
||||
function appendMatch(
|
||||
matches: MatchedGame[],
|
||||
seenGameIds: Set<string>,
|
||||
processName: string,
|
||||
game: Omit<MatchedGame, 'processName'> | null
|
||||
): void {
|
||||
if (!game || seenGameIds.has(game.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenGameIds.add(game.id);
|
||||
matches.push({
|
||||
...game,
|
||||
processName
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user