fix: update ongeki seed scripts to v3 (#1739)

* wip

* fmt

* the rest

* fix the table script

* fix remaster handling in scrape-sdvx-in

* perfect shining
This commit is contained in:
nairobi
2026-09-11 01:38:34 +01:00
committed by GitHub
parent 9460516ea7
commit f08148f864
8 changed files with 367 additions and 169 deletions
@@ -3,49 +3,49 @@
/** Represents the enum public.import_type */
type ImportType =
| 'file/batch-manual'
| 'file/eamusement-iidx-csv'
| 'file/eamusement-sdvx-csv'
| 'file/mypagescraper-player-csv'
| 'file/mypagescraper-records-csv'
| 'file/pli-iidx-csv'
| 'file/solid-state-squad'
| 'api/arc-iidx'
| 'api/arc-sdvx'
| 'api/cg-dev-iidx'
| 'api/cg-dev-jubeat'
| 'api/cg-dev-museca'
| 'api/cg-dev-popn'
| 'api/cg-dev-sdvx'
| 'api/cg-gan-iidx'
| 'api/cg-gan-jubeat'
| 'api/cg-gan-museca'
| 'api/cg-gan-popn'
| 'api/cg-gan-sdvx'
| 'api/cg-nag-iidx'
| 'api/cg-nag-jubeat'
| 'api/cg-nag-museca'
| 'api/cg-nag-popn'
| 'api/cg-nag-sdvx'
| 'api/mer-iidx'
| 'api/eag-iidx'
| 'api/eag-sdvx'
| 'api/flo-iidx'
| 'api/flo-sdvx'
| 'api/mer-iidx'
| 'api/min-sdvx'
| 'api/myt-chunithm'
| 'api/myt-maimaidx'
| 'api/myt-ongeki'
| 'api/myt-wacca'
| 'file/batch-manual'
| 'file/eamusement-iidx-csv'
| 'file/eamusement-sdvx-csv'
| 'file/import-class'
| 'file/mypagescraper-player-csv'
| 'file/mypagescraper-records-csv'
| 'file/pli-iidx-csv'
| 'file/solid-state-squad'
| 'api/cg-dev-jubeat'
| 'api/cg-dev-museca'
| 'api/cg-dev-popn'
| 'api/cg-dev-sdvx'
| 'api/cg-gan-jubeat'
| 'api/cg-gan-museca'
| 'api/cg-gan-popn'
| 'api/cg-gan-sdvx'
| 'api/cg-nag-jubeat'
| 'api/cg-nag-museca'
| 'api/cg-nag-popn'
| 'api/cg-nag-sdvx'
| 'ir/barbatos'
| 'ir/beatoraja'
| 'ir/direct-manual'
| 'ir/fervidex'
| 'ir/fervidex-static'
| 'ir/kshook-sv6c'
| 'ir/fervidex'
| 'ir/kshook-sv6c-static'
| 'ir/kshook-sv6c'
| 'ir/lr2hook'
| 'ir/usc';
| 'ir/usc'
| 'file/import-class'
| 'api/cg-dev-iidx'
| 'api/cg-gan-iidx'
| 'api/cg-nag-iidx';
export default ImportType;
@@ -1,13 +1,16 @@
1. Have a working Tachi Docker container.
2. Parse music data
1. Generate `music.json` (you should know how) without omnimixes or custom charts, then put it in `seeds/scripts/rerunners/ongeki/ongeki`.
2. In the container, in `seeds/scripts/rerunners/ongeki`, run `bun parse-music-data.ts`.
1. Generate `music.json` (you should know how) without omnimixes or custom charts, then put it in `seeds/scripts/personal`.
2. In the container, in `seeds/scripts/rerunners/ongeki`, run `bun parse-music-data.ts -m ../../personal/music.json`.
3. Check if `parse-music-data-output.json` doesn't list anything unexpected, then delete it.
3. Parse song durations
1. Download `vgmstream-cli` and put it somewhere in the container.
2. Run `bun parse-song-duration.ts -v <absolute-path-to-vgmstream-cli> -d <path-to-opts> -g ongeki`.
1. Download `vgmstream-cli` and put it in `seeds/scripts/personal`.
2. Put opts in `seeds/scripts/personal`.
3. Run `bun parse-song-duration.ts -v /tachi/typescript/seeds-scripts/personal/vgmstream-cli -d ../../personal/vgmstream-cli -g ongeki`.
4. Scrape sdvx.in links (optional)
1. Run `bun scrape-sdvx-in.ts`.
2. Fix any missing entries manually in `seeds/collections/charts-ongeki.json` (refer to `songs-ongeki.json` for `songID`s).
5. Manually add search terms in `songs-ongeki.json`, especially romanizations (optional)
6. Run `just db-load-seeds`.
6. Run `bun sync-bonus-track-data.ts` if any bonus tracks are present.
7. Run `bun check-data.ts` and verify the output.
8. Run `just db-load-seeds`.
@@ -1,9 +1,9 @@
import { Command } from "commander";
import { type FolderDocument, GetGameConfig } from "tachi-common";
import { type SeedFolderRow } from "tachi-common/lib/folder-slug";
import { GetGameConfig } from "tachi-common";
import { SEEDS_FolderDocument, SEEDS_TableDocument } from "tachi-common/types/seeds-documents-zod";
import crypto from "crypto";
import { CreateFolderID, CreateTableID, CreateLegacyFolderID, MutateCollection } from "../../util";
import { CreateFolderID, CreateTableID, MutateCollection } from "../../util";
const LEVELS = [
"0",
@@ -32,7 +32,14 @@ const LEVELS = [
"15",
"15+",
];
const DIFFICULTIES = ["BASIC", "ADVANCED", "EXPERT", "MASTER", "LUNATIC", "Re:MASTER"];
const DIFFICULTIES = [
["BASIC", "basic"],
["ADVANCED", "advanced"],
["EXPERT", "expert"],
["MASTER", "master"],
["LUNATIC", "lunatic"],
["Re:MASTER", "lunatic-remaster"],
];
const GENRES = [
["POPS&ANIME", "pna"],
["niconico", "niconico"],
@@ -55,96 +62,88 @@ if (!versionName) {
);
}
const newFolders: SeedFolderRow[] = [];
const levelFolderIDs: string[] = [];
const difficultyFolderIDs: string[] = [];
const newFolders: SEEDS_FolderDocument[] = [];
const levelFolderSlugs: string[] = [];
const difficultyFolderSlugs: string[] = [];
const genreFolderSlugs: string[] = [];
// !!!!!!!!!!!!!!!!!!!!!!!!!!
// !! Not yet updated for V3!
// !!!!!!!!!!!!!!!!!!!!!!!!!!
for (const level of LEVELS) {
const slug = `${level}-${version.toLowerCase()}`;
newFolders.push({
game: "ongeki",
id: CreateFolderID(),
inactive: false,
legacyFolderID: `F${crypto.randomBytes(32).toString("hex")}`,
searchTerms: [],
slug,
title: `Level ${level} (${versionName})`,
versionFilter: [version],
where: `chart.data->>'isBonusTrack' = 'false' AND chart.level = '${level}'`,
});
levelFolderSlugs.push(slug);
}
// for (const level of LEVELS) {
// const data = {
// level,
// versions: version,
// isBonusTrack: false,
// };
// const legacyFolderID = CreateLegacyFolderID(data, "ongeki", "Single");
for (const [fullName, safeName] of DIFFICULTIES) {
const slug = `${version.toLowerCase()}-${safeName}`;
newFolders.push({
game: "ongeki",
id: CreateFolderID(),
inactive: false,
legacyFolderID: `F${crypto.randomBytes(32).toString("hex")}`,
searchTerms: [],
slug,
title: `${fullName} (${versionName})`,
versionFilter: [version],
where: `chart.data->>'isBonusTrack' = 'false' AND chart.difficulty = '${fullName}'`,
});
difficultyFolderSlugs.push(slug);
}
// levelFolderIDs.push(folderID);
// newFolders.push({
// data,
// id: CreateFolderID(),
// legacyFolderID,
// game: "ongeki",
// inactive: false,
// playtype: "Single",
// searchTerms: [],
// title: `Level ${level} (${versionName})`,
// type: "charts",
// });
// }
// for (const difficulty of DIFFICULTIES) {
// const data = { difficulty, versions: version };
// if (difficulty === "LUNATIC") {
// data["data¬isReMaster"] = false;
// } else if (difficulty === "Re:MASTER") {
// data["data¬isReMaster"] = true;
// data.difficulty = "LUNATIC";
// } else {
// data["data¬inGameID"] = {
// "~lt": 7000,
// };
// }
// const folderID = CreateLegacyFolderID(data, "ongeki", "Single");
// difficultyFolderIDs.push(folderID);
// newFolders.push({
// data,
// folderID,
// game: "ongeki",
// inactive: false,
// playtype: "Single",
// searchTerms: [],
// title: `${difficulty} (${versionName})`,
// type: "charts",
// });
// }
for (const [fullName, safeName] of GENRES) {
const slug = `g-${safeName}-${version.toLowerCase()}`;
newFolders.push({
game: "ongeki",
id: CreateFolderID(),
inactive: false,
legacyFolderID: `F${crypto.randomBytes(32).toString("hex")}`,
searchTerms: [],
slug,
title: `${fullName} (${versionName})`,
versionFilter: [version],
where: `(song.data->>'genre')::text = '${fullName}' AND (chart.difficulty = 'MASTER' OR chart.difficulty = 'Re:MASTER')`,
});
genreFolderSlugs.push(slug);
}
MutateCollection("tables.json", (ts) => {
ts.push(
// {
// default: false,
// description: `Levels for O.N.G.E.K.I. in ${versionName}.`,
// folders: levelFolderIDs,
// game: "ongeki",
// inactive: false,
// playtype: "Single",
// tableID: `ongeki-Single-${version}-levels`,
// title: `O.N.G.E.K.I. (${versionName})`,
// },
// {
// default: false,
// description: `Difficulties for O.N.G.E.K.I. in ${versionName}.`,
// folders: difficultyFolderIDs,
// game: "ongeki",
// inactive: false,
// playtype: "Single",
// tableID: `ongeki-Single-${version}-difficulties`,
// title: `O.N.G.E.K.I. (${versionName}) (Difficulties)`,
// },
(ts as SEEDS_TableDocument[]).push(
{
default: false,
description: `Levels for O.N.G.E.K.I. in ${versionName}.`,
folders: levelFolderSlugs,
game: "ongeki",
inactive: false,
id: CreateTableID(),
legacyTableID: `ongeki-Single-${version}-levels`,
title: `O.N.G.E.K.I. (${versionName})`,
},
{
default: false,
description: `Difficulties for O.N.G.E.K.I. in ${versionName}.`,
folders: difficultyFolderSlugs,
game: "ongeki",
inactive: false,
id: CreateTableID(),
legacyTableID: `ongeki-Single-${version}-difficulties`,
title: `O.N.G.E.K.I. (${versionName}) (Difficulties)`,
},
{
default: false,
description: `Genres for O.N.G.E.K.I. in ${versionName}.`,
folders: GENRES.map(([_, g]) => `g-${g}-${version.toLowerCase()}`),
folders: genreFolderSlugs,
game: "ongeki",
id: CreateTableID(),
inactive: false,
id: CreateTableID(),
legacyTableID: `ongeki-Single-${version}-genres`,
title: `O.N.G.E.K.I. (${versionName}) (Genres)`,
},
@@ -153,18 +152,4 @@ MutateCollection("tables.json", (ts) => {
return ts;
});
for (const [fullName, safeName] of GENRES) {
newFolders.push({
game: "ongeki",
id: CreateFolderID(),
inactive: false,
legacyFolderID: `F${crypto.randomBytes(32).toString("hex")}`,
searchTerms: [],
slug: `g-${safeName}-${version.toLowerCase()}`,
title: `${fullName} (${versionName})`,
versionFilter: [version],
where: `(song.data->>'genre')::text = '${fullName}' AND (chart.difficulty = 'MASTER' OR chart.difficulty = 'Re:MASTER')`,
});
}
MutateCollection("folders.json", (fs) => [...fs, ...newFolders]);
MutateCollection("folders.json", (fs) => [...(fs as SEEDS_FolderDocument[]), ...newFolders]);
@@ -0,0 +1,69 @@
import { ReadCollection } from "../../util";
import { SEEDS_ChartDocument, SEEDS_SongDocument } from "tachi-common";
type OngekiSong = SEEDS_SongDocument<"ongeki">;
type OngekiChart = SEEDS_ChartDocument<"ongeki">;
const hasCJK = (str: string) =>
str.match(/[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]/u);
const main = () => {
const songs: OngekiSong[] = ReadCollection("songs-ongeki.json");
const charts: OngekiChart[] = ReadCollection("charts-ongeki.json");
const missingDurations: OngekiSong[] = [];
const missingTerms: OngekiSong[] = [];
const missingChartViews: OngekiSong[] = [];
const missingIDs: Map<OngekiSong, number> = new Map();
for (const song of songs) {
if (song.data.duration === null) {
missingDurations.push(song);
}
if (hasCJK(song.title) && song.searchTerms.length === 0) {
missingTerms.push(song);
}
}
for (const chart of charts) {
const song = songs.find((s) => s.id === chart.songID);
if (song === undefined) {
throw new Error(`Unable to find song ${chart.songID}`);
}
if (
(chart.levelNum >= 12.7 || chart.levelNum === 0) &&
chart.data.chartViewURL === undefined
) {
missingChartViews.push(song);
}
if (chart.data.inGameID === null) {
missingIDs.set(song, (missingIDs.get(song) ?? 0) + 1);
}
}
console.log(
`Missing chart views (${missingChartViews.length} total):\n${missingChartViews
.map((s) => `\t ${s.id} ${s.title}`)
.join("\n")}`,
);
console.log(
`Missing search terms (${missingTerms.length} total):\n${missingTerms
.map((s) => `\t ${s.id} ${s.title}`)
.join("\n")}`,
);
console.log(
`Missing durations (${missingDurations.length} total):\n${missingDurations
.map((s) => `\t ${s.id} ${s.title}`)
.join("\n")}`,
);
console.log(
`Missing IDs (${missingIDs.size} total):\n${[...missingIDs]
.map(([song, count]) => `\t ${song.id} ${song.title} [${count}]`)
.join("\n")}`,
);
};
main();
@@ -1,18 +1,28 @@
// usage: cd typescript/seeds-scripts/rerunners && bun ongeki/parse-music-data.ts
// usage: cd typescript/seeds-scripts/rerunners/ongeki && bun run parse-music-data.ts
import fs from "fs/promises";
import { type ChartDocument, type Difficulties, type SongDocument } from "tachi-common";
import {
CreateSongID,
SEEDS_ChartDocument,
SEEDS_SongDocument,
type Difficulties,
} from "tachi-common";
import { Command } from "commander";
import { CreateChartID, ReadCollection, WriteCollection } from "../../util";
import crypto from "crypto";
type OngekiChart = ChartDocument<"ongeki">;
type OngekiSong = SongDocument<"ongeki">;
type OngekiChart = SEEDS_ChartDocument<"ongeki">;
type OngekiSong = SEEDS_SongDocument<"ongeki">;
type Difficulty = Difficulties["ongeki"];
const command = new Command()
.requiredOption("-m, --musicjson <path-to-music-json>")
.parse(process.argv);
const options = command.opts();
const CURRENT_VERSION = "refresh";
const CURRENT_OMNIMIX = "refreshOmni";
const DRY_RUN = false;
const SOURCE = "ongeki/music.json";
interface Input {
dataVersion: string;
@@ -43,6 +53,7 @@ interface Changes {
versions: string[];
rerates: string[];
renames: string[];
ids: string[];
}
const convertLevel = (chart: InputChart) => {
@@ -73,9 +84,9 @@ const updateChart = (out: OngekiChart, input: InputChart, song: InputSong, chang
changes.versions.push(`${song.name} ${diff}: ${CURRENT_OMNIMIX}`);
out.versions.push(CURRENT_OMNIMIX);
}
if (input.difficulty === "LUNATIC") {
out.data.isReMaster = song.isReMaster;
if (out.data.inGameID === null) {
changes.ids.push(`${song.name} ${diff}: ${song.id}`);
out.data.inGameID = song.id;
}
};
@@ -83,9 +94,9 @@ const main = async () => {
const charts: OngekiChart[] = ReadCollection("charts-ongeki.json");
const songs: OngekiSong[] = ReadCollection("songs-ongeki.json");
const input: Input = JSON.parse((await fs.readFile(SOURCE)).toString());
const input: Input = JSON.parse((await fs.readFile(options.musicjson)).toString());
console.log(`Parsing ${SOURCE} ${input.dataVersion}`);
console.log(`Parsing ${options.musicjson} ${input.dataVersion}`);
const changes: Changes = {
songs: [],
@@ -93,6 +104,7 @@ const main = async () => {
versions: [],
rerates: [],
renames: [],
ids: [],
};
for (const inputSong of input.music) {
@@ -108,13 +120,15 @@ const main = async () => {
song = songs.find((s) => s.title === inputSong.name && s.artist === inputSong.artist);
if (song === undefined) {
song = {
id: songs[songs.length - 1]!.id + 1,
id: CreateSongID(),
legacySongID: songs[songs.length - 1]!.legacySongID + 1,
altTitles: [],
searchTerms: [],
artist: inputSong.artist,
data: {
genre: inputSong.genre as any,
} as any,
duration: null,
},
title: inputSong.name,
};
changes.songs.push(song.title);
@@ -139,6 +153,9 @@ const main = async () => {
}
for (const inputChart of inputSong.charts) {
if (inputChart.difficulty === "LUNATIC" && inputSong.isReMaster) {
inputChart.difficulty = "Re:MASTER";
}
let chart = charts.find(
(c) => c.songID === song!.id && c.difficulty === inputChart.difficulty,
);
@@ -150,24 +167,22 @@ const main = async () => {
ver = `オンゲキ ${ver}`;
}
const newChart: OngekiChart = {
chartID: CreateChartID(),
id: CreateChartID(),
legacyChartID: `C${crypto.randomBytes(19).toString("hex")}`,
songID: song.id,
game: "ongeki",
data: {
displayVersion: ver as any,
inGameID: inputSong.id,
maxPlatScore: inputChart.platinumScoreMax,
isBonusTrack: inputSong.id >= 7000 && inputSong.id < 8000,
},
difficulty: inputChart.difficulty,
isPrimary: true,
level: convertLevel(inputChart),
levelNum: parseFloat(inputChart.internalLevel),
playtype: "Single",
versions: [CURRENT_VERSION, CURRENT_OMNIMIX],
};
if (newChart.difficulty === "LUNATIC") {
newChart.data.isReMaster = inputSong.isReMaster;
}
chart = newChart;
changes.charts.push(`${song.title} ${chart.difficulty} ${chart.level}`);
charts.push(chart);
@@ -2,11 +2,11 @@
import { Command, InvalidArgumentError } from "commander";
import { XMLParser } from "fast-xml-parser";
import { type PathLike } from "fs";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { type ChartDocument, type SongDocument } from "tachi-common";
import { execFile } from "child_process";
import fs from "fs/promises";
import path from "path";
import { promisify } from "util";
import { SEEDS_ChartDocument, SEEDS_SongDocument } from "tachi-common";
import { ReadCollection, WriteCollection } from "../../util";
@@ -16,17 +16,23 @@ const command = new Command()
.requiredOption("-v, --vgms <path-to-vgmstream-cli>")
.requiredOption("-d, --data <path-with-AXXX>")
.requiredOption("-g, --game <ongeki|chunithm>")
.option("-d, --debug")
.parse(process.argv);
const options = command.opts();
const vgmsPath = options.vgms;
const optPath = options.data;
const debug = options.debug;
const game = options.game;
const debugLog = (u: unknown) => {
if (debug) console.log(u);
};
const readOpt = async (
musicPath: string,
charts: ChartDocument<"chunithm" | "ongeki">[],
songs: SongDocument<"chunithm" | "ongeki">[],
charts: SEEDS_ChartDocument<"chunithm" | "ongeki">[],
songs: SEEDS_SongDocument<"chunithm" | "ongeki">[],
) => {
let musicDir: string[];
try {
@@ -38,6 +44,7 @@ const readOpt = async (
const parser = new XMLParser();
for (const songPath of musicDir) {
debugLog(songPath);
const p = path.join(musicPath, songPath);
if (!(await fs.stat(p)).isDirectory()) {
console.log(`${p}: not a directory`);
@@ -60,18 +67,17 @@ const readOpt = async (
const chart = charts.find((c) => c.data.inGameID === id);
if (chart === undefined) {
if (!(game === "chunithm" && id >= 8000)) {
console.error(`Song #${id}: not present in the seeds`);
}
console.error(`Song #${id}: not present in the seeds`);
continue;
}
const song = songs.find((s) => s.id === chart.song.id);
const song = songs.find((s) => s.id === chart.songID);
if (song === undefined) {
console.error(`Song #${id}: orphan`);
continue;
}
if ("duration" in song.data) {
if ("duration" in song.data && song.data.duration !== null) {
debugLog(`Skipping ${song.title}`);
continue;
}
@@ -1,12 +1,12 @@
/* eslint-disable no-await-in-loop */
import readline from "readline";
import { type ChartDocument, type SongDocument } from "tachi-common";
import { SEEDS_ChartDocument, SEEDS_SongDocument } from "tachi-common";
import { ReadCollection, WriteCollection } from "../../util";
type OngekiChart = ChartDocument<"ongeki">;
type OngekiSong = SongDocument<"ongeki">;
type OngekiChart = SEEDS_ChartDocument<"ongeki">;
type OngekiSong = SEEDS_SongDocument<"ongeki">;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const prompt = (query: string) => new Promise((resolve) => rl.question(query, resolve));
@@ -108,8 +108,19 @@ const scrape = async (url: string, allCharts: OngekiChart[], allSongs: OngekiSon
}
const song = songs[0]!;
if (song.id === "S19d35e0fee643d12a81") {
// Perfect shining my behated
continue;
}
const properDiff = convertDiffProper(diff!);
const chart = allCharts.find((c) => c.songID === song.id && c.difficulty === properDiff);
const chart = allCharts.find(
(c) =>
c.songID === song.id &&
(c.difficulty === properDiff ||
(properDiff === "LUNATIC" && c.difficulty === "Re:MASTER")),
);
if (chart) {
chart.data.chartViewURL = link;
@@ -131,13 +142,13 @@ const scrapeAll = async (charts: OngekiChart[], songs: OngekiSong[]) => {
const listMissing = async (charts: OngekiChart[], songs: OngekiSong[], minLevel: number) => {
for (const chart of charts) {
if (chart.data.inGameID >= 7000 && chart.data.inGameID < 8000) {
if (chart.data.isBonusTrack) {
// Bonus track
continue;
}
if (chart.levelNum >= minLevel && !chart.data.chartViewURL) {
const song = songs.find((s) => s.id === chart.song.id);
// @ts-expect-error The linter disagrees with the compiler
const song = songs.find((s) => s.id === chart.songID);
// @ts-expect-error what??
chart.data.chartViewURL = await prompt(
`Missing: ${song?.artist} ${song?.title} ${chart.difficulty} ${chart.level}\n`,
);
@@ -0,0 +1,109 @@
import { ReadCollection, WriteCollection } from "../../util";
import { SEEDS_ChartDocument, SEEDS_SongDocument } from "tachi-common";
type OngekiSong = SEEDS_SongDocument<"ongeki">;
type OngekiChart = SEEDS_ChartDocument<"ongeki">;
const characters = {
"星咲 あかり": "Hoshizaki Akari",
"藤沢 柚子": "Fujisawa Yuzu",
"三角 葵": "Misumi Aoi",
"高瀬 梨緒": "Takase Rio",
"結城 莉玖": "Yūki Riku",
"藍原 椿": "Aihara Tsubaki",
"早乙女 彩華": "Saotome Ayaka",
"桜井 春菜": "Sakurai Haruna",
"柏木 咲姫": "Kashiwagi Saki",
"井之原 小星": "Inohara Koboshi",
"逢坂 茜": "Ōsaka Akane",
"九條 楓": "Kujō Kaede",
"珠洲島 有栖": "Suzushima Arisu",
"日向 千夏": "Hinata Chinatsu",
"柏木 美亜": "Kashiwagi Mia",
"東雲 つむぎ": "Shinonome Tsumugi",
"皇城 セツナ": "Sumeragi Setsuna",
"式宮 舞菜": "Shikimiya Mana",
"式宮 碧音": "Shikimiya Aone",
};
// Songs that have search terms and don't need a romanization
const searchTermBlacklist = ["Iudicium “Apocalypsis Mix”"];
const isBonusTrack = (song: OngekiSong, charts: OngekiChart[]) => {
const chart = charts.find((ch) => ch.songID === song.id);
return chart?.data.isBonusTrack;
};
const splitTitle = (song: OngekiSong) => {
const match = song.title.match(/(.+) +-(.+)ソロver\.-/u);
if (match === null || match.length !== 3) {
throw new Error(`Invalid bonus track title: ${song.title}`);
}
const [, title, rawCharacter] = match;
return {
title: title!.trim(),
rawCharacter: rawCharacter!,
};
};
const findOriginalSong = (originalTitle: string, songs: OngekiSong[]) => {
const results = songs.filter(
// The song Dramatic uses a different question mark in the OG title and bonus titles
(s) => s.title.replace("?", "?") === originalTitle.replace("?", "?"),
);
if (results.length !== 1) {
throw new Error(`Invalid bonus track: ${originalTitle}`);
}
return results[0]!;
};
const main = () => {
const songs: OngekiSong[] = ReadCollection("songs-ongeki.json");
const charts: OngekiChart[] = ReadCollection("charts-ongeki.json");
for (const bonusSong of songs) {
if (!isBonusTrack(bonusSong, charts)) {
continue;
}
const { title, rawCharacter } = splitTitle(bonusSong);
const originalSong = findOriginalSong(title, songs);
bonusSong.data.flavorGenre = originalSong.data.flavorGenre;
if (!(rawCharacter in characters)) {
throw new Error(`Unknown character: ${rawCharacter}`);
}
const character = rawCharacter as keyof typeof characters;
const fixedTitle = title.replace(/?/gu, "?").replace(/…/gu, "...").replace(/!/gu, "!");
if (originalSong.searchTerms.length > 0 && !searchTermBlacklist.includes(title)) {
bonusSong.searchTerms = [
`${originalSong.searchTerms[0]} -${characters[character]} Solo ver.-`,
...originalSong.searchTerms.slice(1),
];
} else {
bonusSong.searchTerms = [
`${fixedTitle} -${characters[character]} Solo ver.-`,
...originalSong.searchTerms,
];
}
for (const chart of charts.filter((ch) => ch.songID === bonusSong.id)) {
const originalChart = charts.find(
(ch) => ch.songID === originalSong.id && ch.difficulty === chart.difficulty,
);
if (originalChart === undefined) {
throw new Error(`Cannot find ${originalSong.title} ${chart.difficulty}`);
}
chart.data.chartViewURL = originalChart.data.chartViewURL;
}
}
WriteCollection(`songs-ongeki.json`, songs);
WriteCollection(`charts-ongeki.json`, charts);
};
main();