import * as path from 'node:path'; import createDebug from 'debug'; import mkdirp from 'mkdirp'; import type { Arguments as ParentArguments } from '../splatnet3.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; import { initStorage } from '../../util/storage.js'; import { getBulletToken } from '../../common/auth/splatnet3.js'; import { dumpCatalogRecords, dumpHistoryRecords, dumpStageStats, dumpWeaponStats } from './dump-records.js'; import { dumpCoopResults, dumpResults } from './dump-results.js'; import { dumpAlbumPhotos } from './dump-album.js'; import SplatNet3Api from '../../api/splatnet3.js'; const debug = createDebug('cli:splatnet3:monitor'); export const command = 'monitor [directory]'; export const desc = 'Monitor SplatNet 3 for new battle and coop results and photo album items'; export function builder(yargs: Argv) { return yargs.positional('directory', { describe: 'Directory to write record data to', type: 'string', }).option('user', { describe: 'Nintendo Account ID', type: 'string', }).option('token', { describe: 'Nintendo Account session token', type: 'string', }).option('battles', { describe: 'Include regular/ranked/private/festival battle results', type: 'boolean', }).option('coop', { describe: 'Include coop (Salmon Run) results', type: 'boolean', }).option('album', { describe: 'Include photo album items', type: 'boolean', }).option('include-history', { describe: 'Include history records', type: 'boolean', default: false, }).option('include-catalog', { describe: 'Include catalog records', type: 'boolean', default: false, }).option('include-stage', { describe: 'Include stage stats', type: 'boolean', default: false, }).option('include-weapon', { describe: 'Include weapon stats', type: 'boolean', default: false, }).option('update-interval', { describe: 'Update interval in seconds', type: 'number', // 15 minutes default: 15 * 60, }); } type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { const _all = [argv.battles, argv.coop, argv.album]; const _default = !_all.find(f => f === true); if (!_all.some(f => f ?? _default)) { throw new Error('Enable one of --battles, --coop or --album'); } const storage = await initStorage(argv.dataPath); const usernsid = argv.user ?? await storage.getItem('SelectedUser'); const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid); const {splatnet} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet3'); await mkdirp(directory); let vs: (ReturnType extends Promise ? T : never) | null = null; let coop: (ReturnType extends Promise ? T : never) | null = null; let album: (ReturnType extends Promise ? T : never) | null = null; if (argv.battles ?? _default) { vs = await dumpResults(splatnet, directory); } if (argv.coop ?? _default) { coop = await dumpCoopResults(splatnet, directory); } if (argv.album ?? _default) { album = await dumpAlbumPhotos(splatnet, directory); } if (argv.includeHistory) { await dumpHistoryRecords(splatnet, directory); } if (argv.includeCatalog) { await dumpCatalogRecords(splatnet, directory); } if (argv.includeStage) { await dumpStageStats(splatnet, directory); } if (argv.includeWeapon) { await dumpWeaponStats(splatnet, directory); } console.warn('Monitoring for new data'); if (vs) { const latest_id = vs.battles.data.latestBattleHistories.historyGroups.nodes[0].historyDetails.nodes[0].id; // If we already had the latest battle result, fetch it again now to match the behavour of Nintendo's app if (!vs.downloaded.includes(latest_id)) { const id_str = Buffer.from(latest_id, 'base64').toString() || latest_id; const match = id_str.match(/^VsHistoryDetail-(u-[0-9a-z]{20}):([A-Z]+):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/); const id = match ? match[1] + '-' + match[3] : id_str; debug('Fetching latest battle result %s', id); const result = await splatnet.getBattleHistoryDetail(latest_id); const pager = await splatnet.getBattleHistoryDetailPagerRefetch(latest_id); } } if (coop) { const latest_id = coop.results.data.coopResult.historyGroups.nodes[0].historyDetails.nodes[0].id; // If we already had the latest coop result, fetch it again now to match the behavour of Nintendo's app if (!coop.downloaded.includes(latest_id)) { const id_str = Buffer.from(latest_id, 'base64').toString() || latest_id; const match = id_str.match(/^CoopHistoryDetail-(u-[0-9a-z]{20}):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/); const id = match ? match[1] + '-' + match[2] : id_str; debug('Fetching latest coop result %s', id); const result = await splatnet.getCoopHistoryDetail(latest_id); } } let updating = false; let should_exit = false; let sleep_timeout: NodeJS.Timeout | null = null; let sleep_resolve: ((value: void) => void) | null = null; const exit = () => { if (updating) { console.warn('Waiting for the current update to complete before exiting'); } should_exit = true; sleep_resolve?.call(null); clearTimeout(sleep_timeout!); process.removeListener('SIGINT' as any, exit); process.removeListener('SIGTERM' as any, exit); }; process.on('SIGINT', exit); process.on('SIGTERM', exit); try { await new Promise(rs => sleep_timeout = setTimeout(sleep_resolve = rs, argv.updateInterval * 1000)); while (!should_exit) { updating = true; [vs, coop, album] = await update(argv, splatnet, directory, vs, coop, album); updating = false; if (should_exit) continue; await new Promise(rs => sleep_timeout = setTimeout(sleep_resolve = rs, argv.updateInterval * 1000)); } } finally { process.removeListener('SIGINT' as any, exit); process.removeListener('SIGTERM' as any, exit); } } async function update( argv: ArgumentsCamelCase, splatnet: SplatNet3Api, directory: string, vs: (ReturnType extends Promise ? T : never) | null, coop: (ReturnType extends Promise ? T : never) | null, album: (ReturnType extends Promise ? T : never) | null, ) { debug('Checking for new data'); let updated_vs = false; let updated_coop = false; if (vs) { const latest_id = vs.battles.data.latestBattleHistories.historyGroups.nodes[0].historyDetails.nodes[0].id; const pager = await splatnet.getBattleHistoryDetailPagerRefetch(latest_id); if (pager.data.vsHistoryDetail.nextHistoryDetail) { // New battle results available debug('New battle result', pager.data.vsHistoryDetail.nextHistoryDetail); vs = await dumpResults(splatnet, directory, vs.battles.data); updated_vs = true; } } if (coop) { const latest_id = coop.results.data.coopResult.historyGroups.nodes[0].historyDetails.nodes[0].id; const pager = await splatnet.getCoopHistoryDetailRefetch(latest_id); if (pager.data.node.nextHistoryDetail) { // New coop results available debug('New coop result', pager.data.node.nextHistoryDetail); coop = await dumpCoopResults(splatnet, directory, coop.results.data); updated_coop = true; } } if (album) { await dumpAlbumPhotos(splatnet, directory, album); } if (argv.includeHistory && (updated_vs || updated_coop)) { await dumpHistoryRecords(splatnet, directory, true); } if (argv.includeCatalog && (updated_vs || updated_coop)) { await dumpCatalogRecords(splatnet, directory, true); } if (argv.includeStage && updated_vs) { await dumpStageStats(splatnet, directory, true); } if (argv.includeWeapon && updated_vs) { await dumpWeaponStats(splatnet, directory, true); } return [vs, coop, album] as const; }