Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
# Auto detect text files and perform LF normalization
* text=auto
# Normalize all text files to LF in the repository and the working tree
* text=auto eol=lf

# Windows batch scripts must keep CRLF to run correctly
*.bat text eol=crlf
*.cmd text eol=crlf
4 changes: 4 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ program
.action(async (options, command) => {
// Build the config entirely from flags when config-building flags are used (and no explicit --config file)
if (!options.config && usedBuildingFlags(command)) {
// Flags take precedence over a config.json; warn if one is present so its silent omission is never a surprise
if (fs.existsSync('config.json')) {
console.warn('Warning: building flags were provided, so the "config.json" in this directory is being ignored. Pass --config config.json to use that file instead, or drop the building flags.');
}
const config = buildConfig(options);
validateConfig(config);
if (options.saveConfig) {
Expand Down
11 changes: 10 additions & 1 deletion config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@
"oneOf": [
{
"const": "array",
"title": "The resulting data structure is an array with its entries being dictionaries holding separate game's proeprties."
"title": "The resulting data structure is an array with its entries being dictionaries holding separate game's properties."
},
{
"const": "productTitle",
Expand Down Expand Up @@ -477,46 +477,55 @@
"Screenshot": {
"description": "In-game screenshots. 16:9 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.4677.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.4344f692-1744-4c18-8024-270fd320f63c",
"type": "integer",
"minimum": -1,
"default": -1
},
"TitledHeroArt": {
"description": "Banner featuring the game's name. 16:9 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.12688.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.ef4d4f2f-1865-4fa7-8ec7-8b914cd4dcc0",
"type": "integer",
"minimum": -1,
"default": -1
},
"Poster": {
"description": "Banner featuring the game's name in portrait mode, such as for smartphones. 2:3 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.64810.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.fdefe49f-270c-44e5-b660-6d7764b37f0f",
"type": "integer",
"minimum": -1,
"default": -1
},
"SuperHeroArt": {
"description": "Artwork without text. 16:9 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.62159.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.1405eb3a-6314-4e44-a822-7660d70a6ec5",
"type": "integer",
"minimum": -1,
"default": -1
},
"Hero": {
"description": "Artwork without text. 2:1 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.28129.13672427983916579.274b1ffd-9cde-4bef-9a3e-6f37073d5ed0.5eb8b4f3-3575-4d13-b0a4-b60d6c64f392",
"type": "integer",
"minimum": -1,
"default": -1
},
"BoxArt": {
"description": "Banner featuring the game's logo and name in a square \"box\" format. 1:1 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.4794.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.b156af1e-9796-48af-8d11-3461727280ea",
"type": "integer",
"minimum": -1,
"default": -1
},
"BrandedKeyArt": {
"description": "Banner featuring the game's name with an \"XBOX\" logo on top. 73:100 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.27624.68326442227858632.21f49c7b-79d7-4647-b847-ecc7a34a7901.1aa31c66-2a52-45d6-8fed-badfb9f25ac6",
"type": "integer",
"minimum": -1,
"default": -1
},
"FeaturePromotionalSquareArt": {
"description": "Banner featuring the game's logo (without name) in a square \"box\" format. 1:1 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.29819.68326442227858632.03782b23-7f26-4a8e-ba87-177bdf2c3c90.322d4aa6-0a23-4565-a64f-743f0620a96e",
"type": "integer",
"minimum": -1,
"default": -1
},
"Logo": {
"description": "Small, square game logo, to be used e.g. as a game library icon. 1:1 aspect ratio. E.g. https://store-images.s-microsoft.com/image/apps.65119.13664397958929388.0e87ac81-8aa3-41f0-82dc-61a295fc5fe3.44bf032a-b113-4179-aa1b-f557dbcd3b19",
"type": "integer",
"minimum": -1,
"default": -1
}
},
Expand Down
27 changes: 20 additions & 7 deletions js/gamePass.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function run(config, { fromDirectory } = {}) {
}
}

const failures = (await Promise.all(tasks)).filter((result) => result && result.failed).length;
const failures = (await Promise.all(tasks)).filter((result) => result?.failed === true).length;
if (failures > 0) {
console.error(`\n${failures} of ${tasks.length} task(s) failed. See the errors above.`);
process.exit(1);
Expand Down Expand Up @@ -128,6 +128,9 @@ async function fetchGameProperties(gameIds, passType, market) {
throw new Error(`Could not parse the Microsoft display catalog API response as JSON: ${error.message ?? error}`);
}

if (data.Products !== undefined && !Array.isArray(data.Products)) {
throw new Error(`The Microsoft display catalog API returned an unexpected (non-array) Products field for ${passType} in market "${market}".`);
}
products.push(...(data.Products ?? []));
}

Expand Down Expand Up @@ -155,7 +158,13 @@ export function formatData(gameProperties, passType) {
index = formattedData.length;
break;
case "productId":
index = game.ProductId;
// Fall back to a placeholder when a game has no ProductId, and disambiguate distinct games that share one so none is silently overwritten
index = game.ProductId ?? `unknown-${Object.keys(formattedData).length}`;
if (Object.prototype.hasOwnProperty.call(formattedData, index)) {
const disambiguated = `${index} (${game.LocalizedProperties?.[0]?.ProductTitle ?? Object.keys(formattedData).length})`;
console.warn(`Duplicate output key "${index}" - writing one entry as "${disambiguated}" to avoid dropping a game.`);
index = disambiguated;
}
break;
case "productTitle":
// Fall back to the ProductId when a game has no localized title, and disambiguate distinct games that share a title so none is silently overwritten in the dictionary
Expand Down Expand Up @@ -266,7 +275,7 @@ function getPublisherName(game, publisherNameProperty) {
: emptyValue();
}

function getProductDescription(game, productDescriptionProperty) {
export function getProductDescription(game, productDescriptionProperty) {
if (!productDescriptionProperty.enabled) { return undefined; }

if (productDescriptionProperty.preferShort && game.LocalizedProperties?.[0]?.ShortDescription?.length > 0) {
Expand All @@ -278,7 +287,7 @@ function getProductDescription(game, productDescriptionProperty) {
}
}

function getImages(game, imageProperty) {
export function getImages(game, imageProperty) {
if (!imageProperty.enabled) { return undefined; }

let images = {};
Expand All @@ -300,6 +309,10 @@ function getImages(game, imageProperty) {
continue;
}

// Skip an image entry with no URI rather than crashing the whole market's task
if (!image.Uri) {
continue;
}
const uri = image.Uri.startsWith('https:') ? image.Uri : `https:${image.Uri}`;

// Skip true duplicates - the same URL can appear more than once in the API response
Expand All @@ -313,7 +326,7 @@ function getImages(game, imageProperty) {
return images;
}

function getReleaseDate(game, releaseDateProperty) {
export function getReleaseDate(game, releaseDateProperty) {
if (!releaseDateProperty.enabled) { return undefined; }

const releaseDate = game.MarketProperties?.[0]?.OriginalReleaseDate;
Expand All @@ -332,7 +345,7 @@ function getReleaseDate(game, releaseDateProperty) {
}
}

function getUserRating(game, userRatingProperty) {
export function getUserRating(game, userRatingProperty) {
if (!userRatingProperty.enabled) { return undefined; }

// Select the requested interval by its label, not by array position, so a reordering or an inserted interval on Microsoft's side cannot silently return the wrong window's rating
Expand All @@ -355,7 +368,7 @@ function getUserRating(game, userRatingProperty) {
return userRating;
}

function getPricing(game, pricingProperty) {
export function getPricing(game, pricingProperty) {
if (!pricingProperty.enabled) { return undefined; }

let missingPricePlaceholder;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "game-pass-api",
"type": "module",
"version": "2.0.0",
"version": "2.1.0",
"description": "CLI to get all games on Xbox Game Pass (Console, PC and EA Play) with their properties formatted just the way you need.",
"keywords": [
"xbox",
Expand Down
55 changes: 54 additions & 1 deletion test/format.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { formatData, getStorePageUrl, getCategories } from '../js/gamePass.js';
import { formatData, getStorePageUrl, getCategories, getImages, getReleaseDate, getUserRating, getPricing } from '../js/gamePass.js';
import { initConfig } from '../js/utils.js';

// A shared temp output directory so initConfig's setupOutput never writes into the repo
Expand Down Expand Up @@ -55,6 +55,59 @@ describe('formatData output shaping', () => {
const out = formatData({ Products: [product(), product({ ProductId: 'D2' })] }, 'console');
assert.deepEqual(Object.keys(out), ['0', '1']);
});

it('disambiguates a duplicate productId instead of silently dropping a game', () => {
useConfig({ outputFormat: 'productId', includedProperties: { productTitle: true } });
// Both products share ABC123; the second must not overwrite the first
const out = formatData({ Products: [product(), product({ LocalizedProperties: [{ ProductTitle: 'Halo 2' }] })] }, 'console');
assert.equal(Object.keys(out).length, 2, 'no game may be dropped when ProductIds collide');
assert.ok('ABC123' in out);
});
});

describe('getImages', () => {
const prop = { enabled: true, imageTypes: { Logo: -1 } };

it('collects image URIs by purpose, prefixing bare //-URIs with https:', () => {
const game = { LocalizedProperties: [{ Images: [{ ImagePurpose: 'Logo', Uri: '//cdn/logo.png' }, { ImagePurpose: 'Logo', Uri: 'https://cdn/logo2.png' }] }] };
assert.deepEqual(getImages(game, prop).Logo, ['https://cdn/logo.png', 'https://cdn/logo2.png']);
});

it('skips an image entry with no Uri instead of crashing the whole task (regression)', () => {
const game = { LocalizedProperties: [{ Images: [{ ImagePurpose: 'Logo' }, { ImagePurpose: 'Logo', Uri: 'https://cdn/ok.png' }] }] };
assert.deepEqual(getImages(game, prop).Logo, ['https://cdn/ok.png']);
});

it('returns undefined when disabled', () => {
assert.equal(getImages({}, { enabled: false }), undefined);
});
});

describe('getReleaseDate, getUserRating and getPricing extractors', () => {
it('formats a release date as a plain date', () => {
useConfig();
const game = { MarketProperties: [{ OriginalReleaseDate: '2021-12-08T00:00:00.0000000Z' }] };
assert.equal(getReleaseDate(game, { enabled: true, format: 'date' }).split('T')[0], '2021-12-08');
});

it('uses the empty-value placeholder for a missing release date', () => {
useConfig({ treatEmptyStringsAsNull: true });
assert.equal(getReleaseDate({}, { enabled: true, format: 'date' }), null);
});

it('selects the user rating by interval label and can format it as a percentage', () => {
useConfig();
const game = { MarketProperties: [{ UsageData: [{ AggregateTimeSpan: 'AllTime', AverageRating: 4 }] }] };
assert.equal(getUserRating(game, { enabled: true, aggregationInterval: 'AllTime', format: 'percentage' }), 0.8);
});

it('preserves a zero price and reads the currency code', () => {
useConfig();
const game = { DisplaySkuAvailabilities: [{ Availabilities: [{ OrderManagementData: { Price: { CurrencyCode: 'USD', ListPrice: 0 } } }] }] };
const out = getPricing(game, { enabled: true, priceTypes: ['ListPrice'], missingPricePolicy: 'useNull' });
assert.equal(out.currencyCode, 'USD');
assert.equal(out.ListPrice, 0);
});
});

describe('formatData property extraction', () => {
Expand Down