getProductInfo(apps, packages, inclTokens, callback, requestType) {
		// Adds support for the previous syntax
		if (typeof inclTokens !== 'boolean' && typeof inclTokens === 'function') {
			requestType = callback;
			callback = inclTokens;
			inclTokens = false;
		}

		// This one actually can take a while, so allow it to go as long as 60 minutes
		return StdLib.Promises.timeoutCallbackPromise(3600000, ['apps', 'packages', 'unknownApps', 'unknownPackages'], callback, (resolve, reject) => {
			requestType = requestType || PICSRequestType.User;

			// Steam can send us the full response in multiple responses, so we need to buffer them into one callback
			let appids = [];
			let packageids = [];
			let response = {
				apps: {},
				packages: {},
				unknownApps: [],
				unknownPackages: []
			};

			apps = apps.map((app) => {
				if (typeof app === 'object') {
					appids.push(app.appid);
					return app;
				} else {
					appids.push(app);
					return {appid: app};
				}
			});

			packages = packages.map((pkg) => {
				if (typeof pkg === 'object') {
					packageids.push(pkg.packageid);
					return pkg;
				} else {
					packageids.push(pkg);
					return {packageid: pkg};
				}
			});

			if (inclTokens) {
				packages.filter(pkg => !pkg.access_token).forEach((pkg) => {
					// Check if we have a license for this package which includes a token
					let license = (this.licenses || []).find(lic => lic.package_id == pkg.packageid && lic.access_token != 0);
					if (license) {
						this.emit('debug', `Using token "${license.access_token}" from license for package ${pkg.packageid}`);
						pkg.access_token = license.access_token;
					}
				});
			}

			this._send(EMsg.ClientPICSProductInfoRequest, {apps, packages}, async (body) => {
				// If we're using the PICS cache, then add the items in this response to it
				if (this.options.enablePicsCache) {
					let cache = this.picsCache;
					cache.apps = cache.apps || {};
					cache.packages = cache.packages || {};

					(body.apps || []).forEach((app) => {
						let appInfoVdf = app.buffer.toString('utf8');
						// It seems that Steam appends a NUL byte. Unsure if this is universal or not, but to make sure
						// that things work regardless of whether there's a NUL byte at the end, just remove it if it's there.
						appInfoVdf = appInfoVdf.replace(/\0$/, '');

						let data = {
							changenumber: app.change_number,
							missingToken: !!app.missing_token,
							appinfo: VDF.parse(appInfoVdf).appinfo
						};

						if ((!cache.apps[app.appid] && requestType == PICSRequestType.Changelist) || (cache.apps[app.appid] && cache.apps[app.appid].changenumber != data.changenumber)) {
							// Only emit the event if we previously didn't have the appinfo, or if the changenumber changed
							this.emit('appUpdate', app.appid, data);
						}

						cache.apps[app.appid] = data;

						app._parsedData = data;
					});

					(body.packages || []).forEach((pkg) => {
						let data = {
							changenumber: pkg.change_number,
							missingToken: !!pkg.missing_token,
							packageinfo: pkg.buffer ? BinaryKVParser.parse(pkg.buffer)[pkg.packageid] : null
						};

						if ((!cache.packages[pkg.packageid] && requestType == PICSRequestType.Changelist) || (cache.packages[pkg.packageid] && cache.packages[pkg.packageid].changenumber != data.changenumber)) {
							this.emit('packageUpdate', pkg.packageid, data);
						}

						cache.packages[pkg.packageid] = data;

						pkg._parsedData = data;

						// Request info for all the apps in this package, if this request didn't originate from the license list
						if (requestType != PICSRequestType.Licenses) {
							let appids = (pkg.packageinfo || {}).appids || [];
							this.getProductInfo(appids, [], false, null, PICSRequestType.PackageContents).catch(() => {});
						}
					});
				}

				(body.unknown_appids || []).forEach((appid) => {
					response.unknownApps.push(appid);
					let index = appids.indexOf(appid);
					if (index != -1) {
						appids.splice(index, 1);
					}
				});

				(body.unknown_packageids || []).forEach((packageid) => {
					response.unknownPackages.push(packageid);
					let index = packageids.indexOf(packageid);
					if (index != -1) {
						packageids.splice(index, 1);
					}
				});

				(body.apps || []).forEach((app) => {
					// _parsedData will be populated if we have the PICS cache enabled.
					// If we don't, we need to parse the data here.

					let appInfoVdf = app.buffer.toString('utf8');
					// It seems that Steam appends a NUL byte. Unsure if this is universal or not, but to make sure
					// that things work regardless of whether there's a NUL byte at the end, just remove it if it's there.
					appInfoVdf = appInfoVdf.replace(/\0$/, '');

					response.apps[app.appid] = app._parsedData || {
						changenumber: app.change_number,
						missingToken: !!app.missing_token,
						appinfo: VDF.parse(appInfoVdf).appinfo
					};

					let index = appids.indexOf(app.appid);
					if (index != -1) {
						appids.splice(index, 1);
					}
				});

				(body.packages || []).forEach((pkg) => {
					response.packages[pkg.packageid] = pkg._parsedData || {
						changenumber: pkg.change_number,
						missingToken: !!pkg.missing_token,
						packageinfo: pkg.buffer ? BinaryKVParser.parse(pkg.buffer)[pkg.packageid] : null
					};

					let index = packageids.indexOf(pkg.packageid);
					if (index != -1) {
						packageids.splice(index, 1);
					}
				});

				// appids and packageids contain the list of IDs that we're still waiting on data for
				if (appids.length === 0 && packageids.length === 0) {
					if (!inclTokens) {
						return resolve(response);
					}

					// We want tokens
					let tokenlessAppids = [];
					let tokenlessPackages = [];

					for (let appid in response.apps) {
						if (response.apps[appid].missingToken) {
							tokenlessAppids.push(parseInt(appid, 10));
						}
					}

					for (let packageid in response.packages) {
						if (response.packages[packageid].missingToken) {
							tokenlessPackages.push(parseInt(packageid, 10));
						}
					}

					if (tokenlessAppids.length == 0 && tokenlessPackages.length == 0) {
						// No tokens needed
						return resolve(response);
					}

					try {
						let {
							appTokens,
							packageTokens
						} = await this.getProductAccessToken(tokenlessAppids, tokenlessPackages);
						let tokenApps = [];
						let tokenPackages = [];

						for (let appid in appTokens) {
							tokenApps.push({appid: parseInt(appid, 10), access_token: appTokens[appid]});
						}

						for (let packageid in packageTokens) {
							tokenPackages.push({
								packageid: parseInt(packageid, 10),
								access_token: packageTokens[packageid]
							});
						}

						// Now we have the tokens. Request the data.
						let {apps, packages} = await this.getProductInfo(tokenApps, tokenPackages, false);
						for (let appid in apps) {
							response.apps[appid] = apps[appid];
							let index = response.unknownApps.indexOf(parseInt(appid, 10));
							if (index != -1) {
								response.unknownApps.splice(index, 1);
							}
						}

						for (let packageid in packages) {
							response.packages[packageid] = packages[packageid];
							let index = response.unknownPackages.indexOf(parseInt(packageid, 10));
							if (index != -1) {
								response.unknownPackages.splice(index, 1);
							}
						}

						resolve(response);
					} catch (ex) {
						return reject(ex);
					}
				}
			});
		});
	}
Get info about some apps and/or packages from Steam.
@param {int[]|object[]} apps - Array of AppIDs. May be empty. May also contain objects with keys {appid, access_token}
@param {int[]|object[]} packages - Array of package IDs. May be empty. May also contain objects with keys {packageid, access_token}
@param {boolean} [inclTokens=false] - If true, automatically retrieve access tokens if needed
@param {function} [callback]
@param {int} [requestType] - Don't touch
@returns {Promise<{apps: Object<string, {changenumber: number, missingToken: boolean, appinfo: object}>, packages: Object<string, {changenumber: number, missingToken: boolean, packageinfo: object}>, unknownApps: number[], unknownPackages: number[]}>}