Files
Tarun Kumar a49d6b441d chore: remove all external-project references, standalone README
Strip every mention of the external reference project from code comments,
scripts and workflows, and rewrite the README as a standalone project doc
(functionality, building, derived data, compatibility database, games
database, automation, deployment) with no outside references except the
KytyPS5 emulator itself.
2026-08-08 01:18:01 +05:30

68 lines
3.0 KiB
JavaScript

// PlayStation Store enrichment helpers: a persisted-query GraphQL fetch for a
// concept's metadata (covers, publisher, release date, genres).
// Persisted-query hash for the store's metGetConceptById operation.
// If the import fails with "persisted-query hash rejected (HTTP 400)": open a
// PS5 game page on store.playstation.com with devtools → Network, filter
// `metGetConceptById`, copy extensions.persistedQuery.sha256Hash, and update
// this constant. [maintainer input]
const PSN_HASH = "cc90404ac049d935afbd9968aef523da2b6723abfb9d586e5f77ebf7c5289006";
/** Merge a PSN concept's metadata into a game entry. */
export function applyEnrichment(game, concept) {
// A missing/stale concept is NOT a permanent state: mark the game noStore but
// leave it unenriched so a later run retries it (the US concept for Neptunia
// ReVerse returned null for months, which used to lock it out of ever getting
// its cover). enrich.mjs only processes unenriched games, so these re-enter
// the pending pool automatically.
if (!concept) return { ...game, noStore: true };
const media = concept.media ?? [];
const cover =
media.find((m) => m.role === "GAMEHUB_COVER_ART")?.url ??
media.find((m) => m.role === "MASTER")?.url;
return {
...game,
name: concept.invariantName || concept.name || game.name,
cover,
publisher: concept.publisherName ?? undefined,
releaseDate: concept.releaseDate?.value ?? undefined,
genres: (concept.combinedLocalizedGenres ?? []).map((g) => g.value),
enriched: true,
};
}
/** Refuse to silently truncate the database on an upstream change. */
export function guardShrink(freshLen, existingLen, force) {
if (force || existingLen === 0) return;
if (freshLen < existingLen * 0.95) {
throw new Error(
`games list shrank ${existingLen} → ${freshLen}; upstream truncation? Re-run with --force to accept.`,
);
}
}
/** Fetch a concept from the public PlayStation Store GraphQL endpoint. */
export async function fetchConcept(conceptId, { retries = 2 } = {}) {
const variables = encodeURIComponent(JSON.stringify({ conceptId: String(conceptId) }));
const extensions = encodeURIComponent(
JSON.stringify({ persistedQuery: { version: 1, sha256Hash: PSN_HASH } }),
);
const url = `https://web.np.playstation.com/api/graphql/v1/op?operationName=metGetConceptById&variables=${variables}&extensions=${extensions}`;
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url, { headers: { "content-type": "application/json" } });
if (res.status === 400) {
throw new Error(
"PSN persisted-query hash rejected (HTTP 400). Refresh PSN_HASH from the live store — see README \"Refreshing the PSN query hash\".",
);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
return body.data?.conceptRetrieve ?? null;
} catch (err) {
if (attempt >= retries || String(err).includes("hash rejected")) throw err;
await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
}
}
}