s3si.ts/src/app.ts

582 lines
15 KiB
TypeScript

import { loginManually } from "./iksm.ts";
import { MultiProgressBar, Mutex } from "../deps.ts";
import { FileStateBackend, Profile, StateBackend } from "./state.ts";
import { Splatnet3 } from "./splatnet3.ts";
import { BattleListType, Game, GameExporter, ListMethod } from "./types.ts";
import { Cache, FileCache } from "./cache.ts";
import { StatInkExporter } from "./exporters/stat.ink.ts";
import { FileExporter } from "./exporters/file.ts";
import { delay, showError } from "./utils.ts";
import { GameFetcher } from "./GameFetcher.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
import { MongoDBExporter } from "./exporters/mongodb.ts";
import { SplashcatExporter } from "./exporters/splashcat.ts";
export type Opts = {
profilePath: string;
exporter: string;
noProgress: boolean;
monitor: boolean;
withSummary: boolean;
withStages: boolean;
skipMode?: string;
listMethod?: string;
cache?: Cache;
stateBackend?: StateBackend;
env: Env;
};
export const DEFAULT_OPTS: Opts = {
profilePath: "./profile.json",
exporter: "stat.ink",
noProgress: false,
monitor: false,
withSummary: false,
withStages: true,
listMethod: "auto",
env: DEFAULT_ENV,
};
type Progress = {
currentUrl?: string;
current: number;
total: number;
};
class StepProgress {
currentUrl?: string;
total: number;
exported: number;
done: number;
skipped: Record<string, number>;
constructor() {
this.total = 1;
this.exported = 0;
this.done = 0;
this.skipped = {};
}
}
interface GameListFetcher {
/**
* Return not exported game list.
* [0] is the latest game.
* @param exporter GameExporter
*/
fetch(exporter: GameExporter): Promise<string[]>;
}
class BattleListFetcher implements GameListFetcher {
protected listMethod: ListMethod;
protected allBattleList?: string[];
protected latestBattleList?: string[];
protected allLock = new Mutex();
protected latestLock = new Mutex();
constructor(
listMethod: string,
protected splatnet: Splatnet3,
) {
if (listMethod === "all") {
this.listMethod = "all";
} else if (listMethod === "latest") {
this.listMethod = "latest";
} else {
this.listMethod = "auto";
}
}
protected getAllBattleList() {
return this.allLock.use(async () => {
if (!this.allBattleList) {
this.allBattleList = await this.splatnet.getAllBattleList();
}
return this.allBattleList;
});
}
protected getLatestBattleList() {
return this.latestLock.use(async () => {
if (!this.latestBattleList) {
this.latestBattleList = await this.splatnet.getBattleList();
}
return this.latestBattleList;
});
}
private async innerFetch(exporter: GameExporter) {
if (this.listMethod === "latest") {
return await exporter.notExported({
type: "VsInfo",
list: await this.getLatestBattleList(),
});
}
if (this.listMethod === "all") {
return await exporter.notExported({
type: "VsInfo",
list: await this.getAllBattleList(),
});
}
if (this.listMethod === "auto") {
const latestList = await exporter.notExported({
type: "VsInfo",
list: await this.getLatestBattleList(),
});
if (latestList.length === 50) {
return await exporter.notExported({
type: "VsInfo",
list: await this.getAllBattleList(),
});
}
return latestList;
}
throw new TypeError(`Unknown listMethod: ${this.listMethod}`);
}
async fetch(exporter: GameExporter) {
return [...await this.innerFetch(exporter)].reverse();
}
}
class CoopListFetcher implements GameListFetcher {
constructor(
protected splatnet: Splatnet3,
) {}
async fetch(exporter: GameExporter) {
return [
...await exporter.notExported({
type: "CoopInfo",
list: await this.splatnet.getBattleList(BattleListType.Coop),
}),
].reverse();
}
}
function progress({ total, currentUrl, done }: StepProgress): Progress {
return {
total,
currentUrl,
current: done,
};
}
export class App {
profile: Profile;
env: Env;
constructor(public opts: Opts) {
const stateBackend = opts.stateBackend ??
new FileStateBackend(opts.profilePath);
this.profile = new Profile({
stateBackend,
env: opts.env,
});
this.env = opts.env;
if (
opts.listMethod && !["all", "auto", "latest"].includes(opts.listMethod)
) {
throw new TypeError(`Unknown listMethod: ${opts.listMethod}`);
}
}
getSkipMode(): ("vs" | "coop")[] {
const mode = this.opts.skipMode;
if (mode === "vs") {
return ["vs"];
} else if (mode === "coop") {
return ["coop"];
}
return [];
}
async getExporters(): Promise<GameExporter[]> {
const state = this.profile.state;
const exporters = this.opts.exporter.split(",");
const out: GameExporter[] = [];
if (exporters.includes("stat.ink")) {
if (!state.statInkApiKey) {
const key = (await this.env.prompts.prompt(
"stat.ink API key is not set. Please enter below.",
)).trim();
if (!key) {
this.env.logger.error("API key is required.");
Deno.exit(1);
}
await this.profile.writeState({
...state,
statInkApiKey: key,
});
}
out.push(
new StatInkExporter({
statInkApiKey: this.profile.state.statInkApiKey!,
uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
env: this.env,
}),
);
}
if (exporters.includes("file")) {
out.push(new FileExporter(state.fileExportPath));
}
if (exporters.includes("mongodb")) {
if (!state.mongoDbUri) {
const uri = (await this.env.prompts.prompt(
"MongoDB URI is not set. Please enter below.",
)).trim();
if (!uri) {
this.env.logger.error("MongoDB URI is required.");
Deno.exit(1);
}
await this.profile.writeState({
...state,
mongoDbUri: uri,
});
}
out.push(
new MongoDBExporter(this.profile.state.mongoDbUri!),
);
}
if (exporters.includes("splashcat")) {
out.push(new SplashcatExporter({
env: this.env,
uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
splashcatApiKey: this.profile.state.splashcatApiKey!,
}));
}
return out;
}
exporterProgress(title: string) {
const bar = !this.opts.noProgress
? new MultiProgressBar({
title,
display: "[:bar] :text :percent :time eta: :eta :completed/:total",
})
: undefined;
const allProgress: Record<string, Progress> = {};
const redraw = (name: string, progress: Progress) => {
allProgress[name] = progress;
if (bar) {
bar.render(
Object.entries(allProgress).map(([name, progress]) => ({
completed: progress.current,
total: progress.total,
text: name,
})),
);
} else if (progress.currentUrl) {
this.env.logger.log(
`Battle exported to ${progress.currentUrl} (${progress.current}/${progress.total})`,
);
}
};
const endBar = () => {
bar?.end();
};
return { redraw, endBar };
}
private async exportOnce() {
const splatnet = new Splatnet3({ profile: this.profile, env: this.env });
const exporters = await this.getExporters();
const initStats = () =>
Object.fromEntries(
exporters.map((e) => [e.name, new StepProgress()]),
);
let stats = initStats();
const skipMode = this.getSkipMode();
const errors: unknown[] = [];
if (skipMode.includes("vs") || exporters.length === 0) {
this.env.logger.log("Skip exporting VS games.");
} else {
const gameListFetcher = new BattleListFetcher(
this.opts.listMethod ?? "auto",
splatnet,
);
const { redraw, endBar } = this.exporterProgress("Export vs games");
const fetcher = new GameFetcher({
cache: this.opts.cache ?? new FileCache(this.profile.state.cacheDir),
state: this.profile.state,
splatnet,
});
const finalRankState = await fetcher.updateRank();
await Promise.all(
exporters.map((e) =>
showError(
this.env,
this.exportGameList({
type: "VsInfo",
fetcher,
exporter: e,
gameListFetcher,
stepProgress: stats[e.name],
onStep: () => {
redraw(e.name, progress(stats[e.name]));
},
}),
)
.catch((err) => {
errors.push(err);
this.env.logger.error(`\nFailed to export to ${e.name}:`, err);
})
),
);
endBar();
this.printStats(stats);
if (errors.length > 0) {
throw errors[0];
}
// save rankState only if all exporters succeeded
fetcher.setRankState(finalRankState);
await this.profile.writeState({
...this.profile.state,
rankState: finalRankState,
});
}
stats = initStats();
if (skipMode.includes("coop") || exporters.length === 0) {
this.env.logger.log("Skip exporting coop games.");
} else {
const gameListFetcher = new CoopListFetcher(splatnet);
const { redraw, endBar } = this.exporterProgress("Export coop games");
const fetcher = new GameFetcher({
cache: this.opts.cache ?? new FileCache(this.profile.state.cacheDir),
state: this.profile.state,
splatnet,
});
await Promise.all(
exporters.map((e) =>
showError(
this.env,
this.exportGameList({
type: "CoopInfo",
fetcher,
exporter: e,
gameListFetcher,
stepProgress: stats[e.name],
onStep: () => {
redraw(e.name, progress(stats[e.name]));
},
}),
)
.catch((err) => {
errors.push(err);
this.env.logger.error(`\nFailed to export to ${e.name}:`, err);
})
),
);
endBar();
this.printStats(stats);
if (errors.length > 0) {
throw errors[0];
}
}
const summaryExporters = exporters.filter((e) => e.exportSummary);
if (!this.opts.withSummary || summaryExporters.length === 0) {
this.env.logger.log("Skip exporting summary.");
} else {
this.env.logger.log("Fetching summary...");
const summary = await splatnet.getSummary();
await Promise.all(
summaryExporters.map((e) =>
showError(
this.env,
e.exportSummary!(summary),
).then((result) => {
if (result.status === "success") {
this.env.logger.log(`Exported summary to ${result.url}`);
} else if (result.status === "skip") {
this.env.logger.log(`Skipped exporting summary to ${e.name}`);
} else {
const _never: never = result;
}
})
.catch((err) => {
errors.push(err);
this.env.logger.error(`\nFailed to export to ${e.name}:`, err);
})
),
);
if (errors.length > 0) {
throw errors[0];
}
}
const stageExporters = exporters.filter((e) => e.exportStages);
if (!this.opts.withStages || stageExporters.length === 0) {
this.env.logger.log("Skip exporting stages.");
} else {
const stageRecords = await splatnet.getStageRecords();
await Promise.all(
stageExporters.map((e) =>
showError(
this.env,
e.exportStages!(stageRecords.stageRecords.nodes),
).then((result) => {
if (result.status === "success") {
this.env.logger.log(`Exported stages to ${result.url}`);
} else if (result.status === "skip") {
this.env.logger.log(`Skipped exporting stages to ${e.name}`);
} else {
const _never: never = result;
}
})
.catch((err) => {
errors.push(err);
this.env.logger.error(`\nFailed to export to ${e.name}:`, err);
})
),
);
}
}
async monitor() {
while (true) {
await this.exportOnce();
await this.countDown(this.profile.state.monitorInterval);
}
}
async countDown(sec: number) {
const bar = !this.opts.noProgress
? new MultiProgressBar({
title: "Killing time...",
display: "[:bar] :completed/:total",
})
: undefined;
for (const i of Array(sec).keys()) {
bar?.render([{
completed: i,
total: sec,
}]);
await delay(1000);
}
bar?.end();
}
async run() {
await this.profile.readState();
if (!this.profile.state.loginState?.sessionToken) {
const sessionToken = await loginManually(this.env);
await this.profile.writeState({
...this.profile.state,
loginState: {
...this.profile.state.loginState,
sessionToken,
},
});
}
if (this.opts.monitor) {
await this.monitor();
} else {
await this.exportOnce();
}
}
/**
* Export game list.
*
* @param fetcher BattleFetcher
* @param exporter BattleExporter
* @param gameList ID list of games, sorted by date, newest first
* @param onStep Callback function called when a game is exported
*/
private async exportGameList({
type,
fetcher,
exporter,
gameListFetcher,
stepProgress,
onStep,
}: {
type: Game["type"];
exporter: GameExporter;
fetcher: GameFetcher;
gameListFetcher: GameListFetcher;
stepProgress: StepProgress;
onStep: () => void;
}): Promise<StepProgress> {
onStep?.();
const workQueue = await gameListFetcher.fetch(exporter);
const step = async (id: string) => {
const detail = await fetcher.fetch(type, id);
const result = await exporter.exportGame(detail);
stepProgress.done += 1;
stepProgress.currentUrl = undefined;
if (result.status === "success") {
stepProgress.exported += 1;
stepProgress.currentUrl = result.url;
} else if (result.status === "skip") {
const { skipped } = stepProgress;
skipped[result.reason] = (skipped[result.reason] ?? 0) + 1;
} else {
const _never: never = result;
}
onStep?.();
};
if (workQueue.length > 0) {
stepProgress.total = workQueue.length;
onStep?.();
for (const battle of workQueue) {
await step(battle);
}
} else {
stepProgress.done = 1;
onStep?.();
}
return stepProgress;
}
printStats(stats: Record<string, StepProgress>) {
this.env.logger.log(
`Exported ${
Object.entries(stats)
.map(([name, { exported }]) => `${name}: ${exported}`)
.join(", ")
}`,
);
if (Object.values(stats).some((i) => Object.keys(i.skipped).length > 0)) {
this.env.logger.log(
`Skipped ${
Object.entries(stats)
.map(([name, { skipped }]) =>
Object.entries(skipped).map(([reason, count]) =>
`${name}: ${reason} (${count})`
).join(", ")
)
}`,
);
}
}
}