diff --git a/package-lock.json b/package-lock.json index 22ff6c8..6c88e71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "node-notifier": "^10.0.1", "node-persist": "^3.1.0", "read": "^1.0.7", - "splatnet3-types": "^0.1.20221021222422", + "splatnet3-types": "^0.1.20221026095800", "supports-color": "^8.1.1", "tslib": "^2.4.0", "uuid": "^8.3.2", @@ -4363,9 +4363,9 @@ "dev": true }, "node_modules/splatnet3-types": { - "version": "0.1.20221021222422", - "resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.1.20221021222422.tgz", - "integrity": "sha512-eAzmc5x4/tVeFGXs0yASlw/fCte4YTV/BmeimkJvH83Y38mOV7J9fghcaKJyDMMvRTcp0hVr3v3feOCzh90o+g==" + "version": "0.1.20221026095800", + "resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.1.20221026095800.tgz", + "integrity": "sha512-2PEFKno7ZYfMqLxZbEU9UCRZV9YV+a3j9P8otn189qPHlURfwTQC5uoQ5s5x2+IEkacYmo2P9ex+RZWzy1q6kA==" }, "node_modules/sprintf-js": { "version": "1.1.2", @@ -8394,9 +8394,9 @@ "dev": true }, "splatnet3-types": { - "version": "0.1.20221021222422", - "resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.1.20221021222422.tgz", - "integrity": "sha512-eAzmc5x4/tVeFGXs0yASlw/fCte4YTV/BmeimkJvH83Y38mOV7J9fghcaKJyDMMvRTcp0hVr3v3feOCzh90o+g==" + "version": "0.1.20221026095800", + "resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.1.20221026095800.tgz", + "integrity": "sha512-2PEFKno7ZYfMqLxZbEU9UCRZV9YV+a3j9P8otn189qPHlURfwTQC5uoQ5s5x2+IEkacYmo2P9ex+RZWzy1q6kA==" }, "sprintf-js": { "version": "1.1.2", diff --git a/package.json b/package.json index 03a508c..64bcd6b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "node-notifier": "^10.0.1", "node-persist": "^3.1.0", "read": "^1.0.7", - "splatnet3-types": "^0.1.20221021222422", + "splatnet3-types": "^0.1.20221026095800", "supports-color": "^8.1.1", "tslib": "^2.4.0", "uuid": "^8.3.2", diff --git a/src/api/splatnet3.ts b/src/api/splatnet3.ts index 7fa9012..a265629 100644 --- a/src/api/splatnet3.ts +++ b/src/api/splatnet3.ts @@ -266,30 +266,54 @@ export default class SplatNet3Api { /** / -> /challenge -> /challenge/{id} */ async getChallengeJourney(id: string) { - return this.persistedQuery(RequestId.JourneyQuery, { + const result = await this.persistedQuery(RequestId.JourneyQuery, { id, }); + + if (!result.data.journey) { + throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /challenge -> /challenge/{id} -> pull-to-refresh */ async getChallengeJourneyRefetch(id: string) { - return this.persistedQuery(RequestId.JourneyRefetchQuery, { + const result = await this.persistedQuery(RequestId.JourneyRefetchQuery, { id, }); + + if (!result.data.journey) { + throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /challenge -> /challenge/{id} -> /challenge/{id}/*s */ async getChallengeJourneyChallenges(id: string) { - return this.persistedQuery(RequestId.JourneyChallengeDetailQuery, { + const result = await this.persistedQuery(RequestId.JourneyChallengeDetailQuery, { journeyId: id, }); + + if (!result.data.journey) { + throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /challenge -> /challenge/{id} -> /challenge/{id}/* -> pull-to-refresh */ async getChallengeJourneyChallengesRefetch(id: string) { - return this.persistedQuery(RequestId.JourneyChallengeDetailRefetchQuery, { + const result = await this.persistedQuery(RequestId.JourneyChallengeDetailRefetchQuery, { journeyId: id, }); + + if (!result.data.journey) { + throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /challenge -> /challenge/{id} -> /challenge/{id}/* -> support */ @@ -315,30 +339,54 @@ export default class SplatNet3Api { /** / -> /fest_record/{id} */ async getFestDetail(id: string) { - return this.persistedQuery(RequestId.DetailFestRecordDetailQuery, { + const result = await this.persistedQuery(RequestId.DetailFestRecordDetailQuery, { festId: id, }); + + if (!result.data.fest) { + throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /fest_record -> /fest_record/{id} -> pull-to-refresh */ async getFestDetailRefetch(id: string) { - return this.persistedQuery(RequestId.DetailFestRefethQuery, { + const result = await this.persistedQuery(RequestId.DetailFestRefethQuery, { festId: id, }); + + if (!result.data.fest) { + throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} */ async getFestVotingStatus(id: string) { - return this.persistedQuery(RequestId.DetailVotingStatusQuery, { + const result = await this.persistedQuery(RequestId.DetailVotingStatusQuery, { festId: id, }); + + if (!result.data.fest) { + throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} -> pull-to-refresh */ async getFestVotingStatusRefetch(id: string) { - return this.persistedQuery(RequestId.DetailFestVotingStatusRefethQuery, { + const result = await this.persistedQuery(RequestId.DetailFestVotingStatusRefethQuery, { festId: id, }); + + if (!result.data.fest) { + throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} - not voted in game */ @@ -350,9 +398,15 @@ export default class SplatNet3Api { /** / -> /fest_record -> /fest_record/{id} - closed -> /fest_record/ranking/{id} */ async getFestRanking(id: string) { - return this.persistedQuery(RequestId.DetailRankingQuery, { + const result = await this.persistedQuery(RequestId.DetailRankingQuery, { festId: id, }); + + if (!result.data.fest) { + throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result); + } + + return result; } // @@ -371,9 +425,15 @@ export default class SplatNet3Api { /** / -> /gesotown -> /gesotown/{id} */ async getSaleGearDetail(id: string) { - return this.persistedQuery(RequestId.SaleGearDetailQuery, { + const result = await this.persistedQuery(RequestId.SaleGearDetailQuery, { saleGearId: id, }); + + if (!result.data.saleGear) { + throw new ErrorResponse('[splatnet3] Sale gear not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /gesotown -> /gesotown/{id} -> order */ @@ -402,18 +462,24 @@ export default class SplatNet3Api { /** / -> /my_outfits -> /my_outfits/{id} */ async getMyOutfitDetail(id: string) { - return this.persistedQuery(RequestId.MyOutfitDetailQuery, { + const result = await this.persistedQuery(RequestId.MyOutfitDetailQuery, { myOutfitId: id, }); + + if (!result.data.myOutfit) { + throw new ErrorResponse('[splatnet3] My outfit not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /my_outfits -> /my_outfits/{id / create} */ - async getEquipmentFilters(id: string) { + async getEquipmentFilters() { return this.persistedQuery(RequestId.MyOutfitCommonDataFilteringConditionQuery, {}); } /** / -> /my_outfits -> /my_outfits/{id / create} */ - async getEquipment(id: string) { + async getEquipment() { return this.persistedQuery(RequestId.MyOutfitCommonDataEquipmentsQuery, {}); } @@ -459,9 +525,15 @@ export default class SplatNet3Api { async getReplaySearchResult(code: string) { if (!REPLAY_CODE_REGEX.test(code)) throw new Error('Invalid replay code'); - return this.persistedQuery(RequestId.DownloadSearchReplayQuery, { + const result = await this.persistedQuery(RequestId.DownloadSearchReplayQuery, { code, }); + + if (!result.data.replay) { + throw new ErrorResponse('[splatnet3] Replay not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /replay -> enter code -> download */ @@ -487,33 +559,73 @@ export default class SplatNet3Api { return this.persistedQuery(RequestId.LatestBattleHistoriesQuery, {}); } + /** / -> /history -> /history/latest -> pull-to-refresh */ + async getLatestBattleHistoriesRefetch() { + return this.persistedQuery(RequestId.LatestBattleHistoriesRefetchQuery, { + fetchCurrentPlayer: true, + }); + } + /** / -> /history */ async getRegularBattleHistories() { return this.persistedQuery(RequestId.RegularBattleHistoriesQuery, {}); } + /** / -> /history -> /history/regular -> pull-to-refresh */ + async getRegularBattleHistoriesRefetch() { + return this.persistedQuery(RequestId.RegularBattleHistoriesRefetchQuery, { + fetchCurrentPlayer: true, + }); + } + /** / -> /history */ async getBankaraBattleHistories() { return this.persistedQuery(RequestId.BankaraBattleHistoriesQuery, {}); } + /** / -> /history -> /history/bankara -> pull-to-refresh */ + async getBankaraBattleHistoriesRefetch() { + return this.persistedQuery(RequestId.BankaraBattleHistoriesRefetchQuery, { + fetchCurrentPlayer: true, + }); + } + /** / -> /history */ async getPrivateBattleHistories() { return this.persistedQuery(RequestId.PrivateBattleHistoriesQuery, {}); } + /** / -> /history -> /history/private -> pull-to-refresh */ + async getPrivateBattleHistoriesRefetch() { + return this.persistedQuery(RequestId.PrivateBattleHistoriesRefetchQuery, { + fetchCurrentPlayer: true, + }); + } + /** / -> /history -> /history/detail/{id} */ async getBattleHistoryDetail(id: string) { - return this.persistedQuery(RequestId.VsHistoryDetailQuery, { + const result = await this.persistedQuery(RequestId.VsHistoryDetailQuery, { vsResultId: id, }); + + if (!result.data.vsHistoryDetail) { + throw new ErrorResponse('[splatnet3] Battle history not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /history -> /history/detail/{id} -> pull-to-refresh */ async getBattleHistoryDetailPagerRefetch(id: string) { - return this.persistedQuery(RequestId.VsHistoryDetailPagerRefetchQuery, { + const result = await this.persistedQuery(RequestId.VsHistoryDetailPagerRefetchQuery, { vsResultId: id, }); + + if (!result.data.vsHistoryDetail) { + throw new ErrorResponse('[splatnet3] Battle history not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /history -> /history/detail/* -> latest */ @@ -548,16 +660,28 @@ export default class SplatNet3Api { /** / -> /coop -> /coop/{id} */ async getCoopHistoryDetail(id: string) { - return this.persistedQuery(RequestId.CoopHistoryDetailQuery, { + const result = await this.persistedQuery(RequestId.CoopHistoryDetailQuery, { coopHistoryDetailId: id, }); + + if (!result.data.coopHistoryDetail) { + throw new ErrorResponse('[splatnet3] Co-op history not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /coop -> /coop/{id} -> pull-to-refresh */ async getCoopHistoryDetailRefetch(id: string) { - return this.persistedQuery(RequestId.CoopHistoryDetailRefetchQuery, { + const result = await this.persistedQuery(RequestId.CoopHistoryDetailRefetchQuery, { id, }); + + if (!result.data.node) { + throw new ErrorResponse('[splatnet3] Co-op history not found', result[ResponseSymbol], result); + } + + return result; } /** / -> /coop -> /coop/* -> latest */ diff --git a/src/cli/splatnet3/dump-album.ts b/src/cli/splatnet3/dump-album.ts index 4789423..7e06590 100644 --- a/src/cli/splatnet3/dump-album.ts +++ b/src/cli/splatnet3/dump-album.ts @@ -52,7 +52,7 @@ export async function dumpAlbumPhotos( refresh: PhotoAlbumResult | boolean = false ) { debug('Fetching photo album items'); - console.warn('Fetching photo album items'); + if (typeof refresh !== 'object') console.warn('Fetching photo album items'); const results = refresh ? await splatnet.getPhotoAlbumRefetch() : diff --git a/src/cli/splatnet3/dump-results.ts b/src/cli/splatnet3/dump-results.ts index b8be1b6..2b5f6d9 100644 --- a/src/cli/splatnet3/dump-results.ts +++ b/src/cli/splatnet3/dump-results.ts @@ -2,13 +2,14 @@ import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import createDebug from 'debug'; import mkdirp from 'mkdirp'; -import { RequestId } from 'splatnet3-types/splatnet3'; +import { BankaraBattleHistoriesRefetchResult, CoopHistoryResult, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesResult, PrivateBattleHistoriesRefetchResult, RegularBattleHistoriesRefetchResult, RequestId } from 'splatnet3-types/splatnet3'; 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 SplatNet3Api from '../../api/splatnet3.js'; import { ResponseSymbol } from '../../api/util.js'; +import { dumpCatalogRecords, dumpHistoryRecords } from './dump-records.js'; const debug = createDebug('cli:splatnet3:dump-results'); @@ -31,12 +32,27 @@ export function builder(yargs: Argv) { }).option('coop', { describe: 'Include coop (Salmon Run) results', type: 'boolean', + }).option('include-history', { + describe: 'Include history records', + type: 'boolean', + default: false, + }).option('include-catalog', { + describe: 'Include catalog records', + type: 'boolean', + default: false, }); } type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { + const _all = [argv.battles, argv.coop]; + const _default = !_all.find(f => f === true); + + if (!_all.some(f => f ?? _default)) { + throw new Error('Enable one of --battles or --coop'); + } + const storage = await initStorage(argv.dataPath); const usernsid = argv.user ?? await storage.getItem('SelectedUser'); @@ -48,28 +64,34 @@ export async function handler(argv: ArgumentsCamelCase) { await mkdirp(directory); - const _default = typeof argv.battles !== 'boolean' && typeof argv.coop !== 'boolean'; - if (argv.battles ?? _default) { await dumpResults(splatnet, directory); } if (argv.coop ?? _default) { await dumpCoopResults(splatnet, directory); } + + if (argv.includeHistory) { + await dumpHistoryRecords(splatnet, directory); + } + if (argv.includeCatalog) { + await dumpCatalogRecords(splatnet, directory); + } } export async function dumpResults( - splatnet: SplatNet3Api, directory: string + splatnet: SplatNet3Api, directory: string, + refresh: LatestBattleHistoriesResult | boolean = false ) { debug('Fetching battle results'); console.warn('Fetching battle results'); const [player, battles, battles_regular, battles_anarchy, battles_private] = await Promise.all([ - splatnet.getBattleHistoryCurrentPlayer(), - splatnet.getLatestBattleHistories(), - splatnet.getRegularBattleHistories(), - splatnet.getBankaraBattleHistories(), - splatnet.getPrivateBattleHistories(), + refresh ? null : splatnet.getBattleHistoryCurrentPlayer(), + refresh ? splatnet.getLatestBattleHistoriesRefetch() : splatnet.getLatestBattleHistories(), + refresh ? splatnet.getRegularBattleHistoriesRefetch() : splatnet.getRegularBattleHistories(), + refresh ? splatnet.getBankaraBattleHistoriesRefetch() : splatnet.getBankaraBattleHistories(), + refresh ? splatnet.getPrivateBattleHistoriesRefetch() : splatnet.getPrivateBattleHistories(), ]); const filename = 'splatnet3-results-summary-' + Date.now() + '.json'; @@ -77,40 +99,49 @@ export async function dumpResults( debug('Writing %s', filename); await fs.writeFile(file, JSON.stringify({ - player: { + player: player ? { result: player.data.currentPlayer, query: RequestId.BattleHistoryCurrentPlayerQuery, be_version: player[ResponseSymbol].headers.get('x-be-version'), - }, + } : undefined, latestBattleHistories: { result: battles.data.latestBattleHistories, fest: battles.data.currentFest, - query: RequestId.LatestBattleHistoriesQuery, + player: 'currentPlayer' in battles.data ? + (battles.data as LatestBattleHistoriesRefetchResult).currentPlayer : undefined, + query: refresh ? RequestId.LatestBattleHistoriesRefetchQuery : RequestId.LatestBattleHistoriesQuery, be_version: battles[ResponseSymbol].headers.get('x-be-version'), }, regularBattleHistories: { result: battles_regular.data.regularBattleHistories, - query: RequestId.RegularBattleHistoriesQuery, + player: 'currentPlayer' in battles_regular.data ? + (battles_regular.data as RegularBattleHistoriesRefetchResult).currentPlayer : undefined, + query: refresh ? RequestId.RegularBattleHistoriesRefetchQuery : RequestId.RegularBattleHistoriesQuery, be_version: battles_regular[ResponseSymbol].headers.get('x-be-version'), }, bankaraBattleHistories: { result: battles_anarchy.data.bankaraBattleHistories, - query: RequestId.BankaraBattleHistoriesQuery, + player: 'currentPlayer' in battles_anarchy.data ? + (battles_anarchy.data as BankaraBattleHistoriesRefetchResult).currentPlayer : undefined, + query: refresh ? RequestId.BankaraBattleHistoriesRefetchQuery : RequestId.BankaraBattleHistoriesQuery, be_version: battles_anarchy[ResponseSymbol].headers.get('x-be-version'), }, privateBattleHistories: { result: battles_private.data.privateBattleHistories, - query: RequestId.PrivateBattleHistoriesQuery, + player: 'currentPlayer' in battles_private.data ? + (battles_private.data as PrivateBattleHistoriesRefetchResult).currentPlayer : undefined, + query: refresh ? RequestId.PrivateBattleHistoriesRefetchQuery : RequestId.PrivateBattleHistoriesQuery, be_version: battles_private[ResponseSymbol].headers.get('x-be-version'), }, app_version: splatnet.version, }, null, 4) + '\n', 'utf-8'); + const downloaded = []; const skipped = []; // Reverse battle history order so oldest records are downloaded first - for (const group of battles.data.latestBattleHistories.historyGroups.nodes.reverse()) { - for (const item of group.historyDetails.nodes.reverse()) { + for (const group of [...battles.data.latestBattleHistories.historyGroups.nodes].reverse()) { + for (const item of [...group.historyDetails.nodes].reverse()) { const id_str = Buffer.from(item.id, 'base64').toString() || item.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; @@ -134,6 +165,8 @@ export async function dumpResults( app_version: splatnet.version, be_version: result[ResponseSymbol].headers.get('x-be-version'), }, null, 4) + '\n', 'utf-8'); + + downloaded.push(item.id); } } } @@ -142,12 +175,27 @@ export async function dumpResults( if (skipped.length === 1) debug('Skipped battle result %s, file already exists', skipped[0]); else debug('Skipped %d battle results, files already exist', skipped.length); } + + return { + player, + battles, + battles_regular, + battles_anarchy, + battles_private, + downloaded, + }; } -export async function dumpCoopResults(splatnet: SplatNet3Api, directory: string) { +export async function dumpCoopResults( + splatnet: SplatNet3Api, directory: string, + refresh: CoopHistoryResult | boolean = false +) { debug('Fetching coop results'); console.warn('Fetching coop results'); - const results = await splatnet.getCoopHistory(); + + const results = refresh ? + await splatnet.getCoopHistoryRefetch() : + await splatnet.getCoopHistory(); const filename = 'splatnet3-coop-summary-' + Date.now() + '.json'; const file = path.join(directory, filename); @@ -155,16 +203,17 @@ export async function dumpCoopResults(splatnet: SplatNet3Api, directory: string) debug('Writing %s', filename); await fs.writeFile(file, JSON.stringify({ result: results.data.coopResult, - query: RequestId.CoopHistoryQuery, + query: refresh ? RequestId.RefetchableCoopHistory_CoopResultQuery : RequestId.CoopHistoryQuery, app_version: splatnet.version, be_version: results[ResponseSymbol].headers.get('x-be-version'), }, null, 4) + '\n', 'utf-8'); + const downloaded = []; const skipped = []; // Reverse coop history order so oldest records are downloaded first - for (const group of results.data.coopResult.historyGroups.nodes.reverse()) { - for (const item of group.historyDetails.nodes.reverse()) { + for (const group of [...results.data.coopResult.historyGroups.nodes].reverse()) { + for (const item of [...group.historyDetails.nodes].reverse()) { const id_str = Buffer.from(item.id, 'base64').toString() || item.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; @@ -187,6 +236,8 @@ export async function dumpCoopResults(splatnet: SplatNet3Api, directory: string) app_version: splatnet.version, be_version: result[ResponseSymbol].headers.get('x-be-version'), }, null, 4) + '\n', 'utf-8'); + + downloaded.push(item.id); } } } @@ -195,4 +246,9 @@ export async function dumpCoopResults(splatnet: SplatNet3Api, directory: string) if (skipped.length === 1) debug('Skipped co-op result %s, file already exist', skipped[0]); else debug('Skipped %d co-op results, files already exist', skipped.length); } + + return { + results, + downloaded, + }; } diff --git a/src/cli/splatnet3/index.ts b/src/cli/splatnet3/index.ts index b1aef27..74c0bf9 100644 --- a/src/cli/splatnet3/index.ts +++ b/src/cli/splatnet3/index.ts @@ -9,3 +9,4 @@ export * as dumpRecords from './dump-records.js'; export * as dumpFests from './dump-fests.js'; export * as dumpAlbum from './dump-album.js'; export * as dumpResults from './dump-results.js'; +export * as monitor from './monitor.js'; diff --git a/src/cli/splatnet3/monitor.ts b/src/cli/splatnet3/monitor.ts new file mode 100644 index 0000000..618b306 --- /dev/null +++ b/src/cli/splatnet3/monitor.ts @@ -0,0 +1,233 @@ +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; +}