mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-12 17:28:31 +03:00
feat: second half of search functionality
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import DB from "#services/pg/db";
|
||||
import type {
|
||||
ChartDocument,
|
||||
ChartDocumentData,
|
||||
Difficulties,
|
||||
GPTString,
|
||||
Versions,
|
||||
} from "tachi-common";
|
||||
import { V3ToGamePT } from "tachi-common";
|
||||
import type { Game } from "tachi-db";
|
||||
|
||||
import DB from "#services/pg/db";
|
||||
import { sql, type SqlBool } from "kysely";
|
||||
import {
|
||||
type ChartDocument,
|
||||
type ChartDocumentData,
|
||||
type Difficulties,
|
||||
type GPTString,
|
||||
V3ToGamePT,
|
||||
type Versions,
|
||||
} from "tachi-common";
|
||||
|
||||
/**
|
||||
* Fetches all charts for a given PG game string (e.g. "iidx-sp") and song PG UUID,
|
||||
* including their version tags from `chart_version`. Returns fully-formed
|
||||
@@ -18,8 +20,9 @@ export async function GetChartsBySongPgId(
|
||||
gamePt: Game,
|
||||
songPgId: string,
|
||||
songLegacyId: number,
|
||||
opts?: { omit2dxtraCharts?: boolean },
|
||||
): Promise<ChartDocument[]> {
|
||||
const rows = await DB.selectFrom("chart")
|
||||
let q = DB.selectFrom("chart")
|
||||
.select([
|
||||
"id",
|
||||
"legacy_id",
|
||||
@@ -31,8 +34,13 @@ export async function GetChartsBySongPgId(
|
||||
"data",
|
||||
])
|
||||
.where("song_id", "=", songPgId)
|
||||
.where("game", "=", gamePt)
|
||||
.execute();
|
||||
.where("game", "=", gamePt);
|
||||
|
||||
if (opts?.omit2dxtraCharts && String(gamePt).startsWith("iidx-")) {
|
||||
q = q.where(sql<SqlBool>`(data->>'2dxtraSet') IS NULL`);
|
||||
}
|
||||
|
||||
const rows = await q.execute();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { SearchSpecificGameSongs } from "#lib/search/search";
|
||||
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
|
||||
import { SearchGlobalGameSongsAndCharts, SearchSpecificGameSongs } from "#lib/search/search";
|
||||
import DB from "#services/pg/db";
|
||||
import { importSeedsSubset } from "#services/pg/seeds";
|
||||
import { resolveSeedsDir, seedsJsonAvailable } from "#test-utils/seed-paths";
|
||||
import { FindChartsOnPopularity } from "#utils/queries/charts";
|
||||
import { sql } from "kysely";
|
||||
import { GamePTToV3 } from "tachi-common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
LoadSongChildrenForPgIds,
|
||||
MAX_SONG_SEARCH_RESULTS_PER_GAME,
|
||||
SearchSongsForGameFtsAndTrgm,
|
||||
SHORT_QUERY_STRICT_MAX_LEN,
|
||||
} from "./song-search";
|
||||
|
||||
function makeSongId(n: number): string {
|
||||
return `S${n.toString(16).padStart(20, "0")}`;
|
||||
}
|
||||
|
||||
function makeChartId(n: number): string {
|
||||
return `C${n.toString(16).padStart(20, "0")}`;
|
||||
}
|
||||
|
||||
async function countSongRows(): Promise<number> {
|
||||
const { rows } = await sql<{ c: bigint }>`
|
||||
SELECT count(*)::bigint AS c FROM song
|
||||
@@ -134,7 +142,7 @@ describe("SearchSongsForGameFtsAndTrgm (synthetic rows)", () => {
|
||||
expect(res.length).toBe(MAX_SONG_SEARCH_RESULTS_PER_GAME);
|
||||
});
|
||||
|
||||
it("uses trgm / short-query path for very short queries", async () => {
|
||||
it("finds a two-letter title via exact match (strict short query, not substring trgm)", async () => {
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: makeSongId(300),
|
||||
@@ -153,6 +161,127 @@ describe("SearchSongsForGameFtsAndTrgm (synthetic rows)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchSongsForGameFtsAndTrgm (very short titles / strict query)", () => {
|
||||
it("does not flood results for a single-letter search: only exact title/artist (and terms)", async () => {
|
||||
await DB.insertInto("song")
|
||||
.values([
|
||||
{
|
||||
id: makeSongId(700),
|
||||
legacy_id: 9_000_700,
|
||||
game_group: "iidx",
|
||||
title: "A",
|
||||
artist: "Some Artist",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
},
|
||||
{
|
||||
id: makeSongId(701),
|
||||
legacy_id: 9_000_701,
|
||||
game_group: "iidx",
|
||||
title: "About Something",
|
||||
artist: "B",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
},
|
||||
{
|
||||
id: makeSongId(702),
|
||||
legacy_id: 9_000_702,
|
||||
game_group: "iidx",
|
||||
title: "Many Letters",
|
||||
artist: "Alpha",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const res = await SearchSongsForGameFtsAndTrgm("iidx", "A", 20);
|
||||
const legacyIds = res.map((r) => r.legacy_id);
|
||||
|
||||
expect(legacyIds).toContain(9_000_700);
|
||||
expect(legacyIds).not.toContain(9_000_701);
|
||||
expect(legacyIds).not.toContain(9_000_702);
|
||||
});
|
||||
|
||||
it("distinguishes one-letter title from two-letter title when searching one letter", async () => {
|
||||
await DB.insertInto("song")
|
||||
.values([
|
||||
{
|
||||
id: makeSongId(710),
|
||||
legacy_id: 9_000_710,
|
||||
game_group: "iidx",
|
||||
title: "A",
|
||||
artist: "X",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
},
|
||||
{
|
||||
id: makeSongId(711),
|
||||
legacy_id: 9_000_711,
|
||||
game_group: "iidx",
|
||||
title: "AA",
|
||||
artist: "Y",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const one = await SearchSongsForGameFtsAndTrgm("iidx", "A", 20);
|
||||
|
||||
expect(one.map((r) => r.legacy_id)).toContain(9_000_710);
|
||||
expect(one.map((r) => r.legacy_id)).not.toContain(9_000_711);
|
||||
|
||||
const two = await SearchSongsForGameFtsAndTrgm("iidx", "AA", 20);
|
||||
|
||||
expect(two.map((r) => r.legacy_id)).toContain(9_000_711);
|
||||
expect(two.map((r) => r.legacy_id)).not.toContain(9_000_710);
|
||||
});
|
||||
|
||||
it("matches exact search_term when query is within strict length", async () => {
|
||||
const sid = makeSongId(720);
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: sid,
|
||||
legacy_id: 9_000_720,
|
||||
game_group: "iidx",
|
||||
title: "Long Title",
|
||||
artist: "Z",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
await DB.insertInto("song_search_term")
|
||||
.values({ song_id: sid, search_term: "V" })
|
||||
.execute();
|
||||
|
||||
const res = await SearchSongsForGameFtsAndTrgm("iidx", "V", 10);
|
||||
|
||||
expect(res.some((r) => r.id === sid)).toBe(true);
|
||||
});
|
||||
|
||||
it("still uses substring trgm for three-character queries when FTS is weak", async () => {
|
||||
expect(SHORT_QUERY_STRICT_MAX_LEN).toBe(2);
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: makeSongId(730),
|
||||
legacy_id: 9_000_730,
|
||||
game_group: "iidx",
|
||||
title: "ZzzUniqueToken",
|
||||
artist: "Z",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const res = await SearchSongsForGameFtsAndTrgm("iidx", "Zzz", 10);
|
||||
|
||||
expect(res.some((r) => r.legacy_id === 9_000_730)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchSongsForGameFtsAndTrgm (hostile / injection-shaped input)", () => {
|
||||
it("does not change song row count when search strings look like SQL injection", async () => {
|
||||
await DB.insertInto("song")
|
||||
@@ -276,6 +405,284 @@ describe("SearchSpecificGameSongs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("IIDX 2dxtraSet exclusion from search", () => {
|
||||
it("excludes songs that only have charts with 2dxtraSet set", async () => {
|
||||
const sid = makeSongId(860);
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: sid,
|
||||
legacy_id: 9_000_860,
|
||||
game_group: "iidx",
|
||||
title: "Only2dxtraSearchToken",
|
||||
artist: "X",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: makeChartId(860),
|
||||
legacy_id: "a".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "12",
|
||||
level_num: 12,
|
||||
is_primary: true,
|
||||
difficulty: "ANOTHER",
|
||||
data: JSON.stringify({ "2dxtraSet": "test-set", notecount: 100 }),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const res = await SearchSongsForGameFtsAndTrgm("iidx", "Only2dxtraSearchToken", 20);
|
||||
|
||||
expect(res.some((r) => r.legacy_id === 9_000_860)).toBe(false);
|
||||
});
|
||||
|
||||
it("includes iidx songs with at least one non-2dxtra chart", async () => {
|
||||
const sid = makeSongId(861);
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: sid,
|
||||
legacy_id: 9_000_861,
|
||||
game_group: "iidx",
|
||||
title: "Mixed2dxtraSearchToken",
|
||||
artist: "X",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values([
|
||||
{
|
||||
id: makeChartId(861),
|
||||
legacy_id: "b".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "12",
|
||||
level_num: 12,
|
||||
is_primary: true,
|
||||
difficulty: "ANOTHER",
|
||||
data: JSON.stringify({ "2dxtraSet": "x", notecount: 100 }),
|
||||
},
|
||||
{
|
||||
id: makeChartId(862),
|
||||
legacy_id: "c".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "11",
|
||||
level_num: 11,
|
||||
is_primary: false,
|
||||
difficulty: "HYPER",
|
||||
data: JSON.stringify({ "2dxtraSet": null, notecount: 80 }),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const res = await SearchSongsForGameFtsAndTrgm("iidx", "Mixed2dxtraSearchToken", 20);
|
||||
|
||||
expect(res.some((r) => r.legacy_id === 9_000_861)).toBe(true);
|
||||
});
|
||||
|
||||
it("GetChartsBySongPgId omits 2dxtra charts when omit2dxtraCharts is true", async () => {
|
||||
const sid = makeSongId(870);
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: sid,
|
||||
legacy_id: 9_000_870,
|
||||
game_group: "iidx",
|
||||
title: "ChartFilter",
|
||||
artist: "X",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values([
|
||||
{
|
||||
id: makeChartId(871),
|
||||
legacy_id: "d".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "12",
|
||||
level_num: 12,
|
||||
is_primary: true,
|
||||
difficulty: "ANOTHER",
|
||||
data: JSON.stringify({ "2dxtraSet": "x", notecount: 100 }),
|
||||
},
|
||||
{
|
||||
id: makeChartId(872),
|
||||
legacy_id: "e".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "11",
|
||||
level_num: 11,
|
||||
is_primary: false,
|
||||
difficulty: "HYPER",
|
||||
data: JSON.stringify({ notecount: 80 }),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const all = await GetChartsBySongPgId(GamePTToV3("iidx", "SP"), sid, 9_000_870);
|
||||
const filtered = await GetChartsBySongPgId(GamePTToV3("iidx", "SP"), sid, 9_000_870, {
|
||||
omit2dxtraCharts: true,
|
||||
});
|
||||
|
||||
expect(all).toHaveLength(2);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]?.difficulty).toBe("HYPER");
|
||||
});
|
||||
|
||||
it("SearchGlobalGameSongsAndCharts never returns 2dxtra charts for iidx", async () => {
|
||||
const sid = makeSongId(880);
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: sid,
|
||||
legacy_id: 9_000_880,
|
||||
game_group: "iidx",
|
||||
title: "GlobalSearch2dxtraToken",
|
||||
artist: "X",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values([
|
||||
{
|
||||
id: makeChartId(880),
|
||||
legacy_id: "f".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "12",
|
||||
level_num: 12,
|
||||
is_primary: true,
|
||||
difficulty: "ANOTHER",
|
||||
data: JSON.stringify({ "2dxtraSet": "set-a", notecount: 100 }),
|
||||
},
|
||||
{
|
||||
id: makeChartId(881),
|
||||
legacy_id: "0".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "11",
|
||||
level_num: 11,
|
||||
is_primary: false,
|
||||
difficulty: "HYPER",
|
||||
data: JSON.stringify({ notecount: 80 }),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const rows = await SearchGlobalGameSongsAndCharts(
|
||||
"iidx",
|
||||
"GlobalSearch2dxtraToken",
|
||||
"SP",
|
||||
20,
|
||||
);
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
rows.every((r) => {
|
||||
const v = (r.chart.data as Record<string, unknown>)["2dxtraSet"];
|
||||
|
||||
return v === null || v === undefined;
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(rows.some((r) => r.chart.difficulty === "HYPER")).toBe(true);
|
||||
});
|
||||
|
||||
it("FindChartsOnPopularity excludes 2dxtra charts for iidx", async () => {
|
||||
const sid = makeSongId(890);
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: sid,
|
||||
legacy_id: 9_000_890,
|
||||
game_group: "iidx",
|
||||
title: "Pop2dxtraToken",
|
||||
artist: "X",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values([
|
||||
{
|
||||
id: makeChartId(890),
|
||||
legacy_id: "1".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "12",
|
||||
level_num: 12,
|
||||
is_primary: true,
|
||||
difficulty: "ANOTHER",
|
||||
data: JSON.stringify({ "2dxtraSet": "x", notecount: 100 }),
|
||||
},
|
||||
{
|
||||
id: makeChartId(891),
|
||||
legacy_id: "2".repeat(40),
|
||||
game: "iidx-sp",
|
||||
song_id: sid,
|
||||
level: "11",
|
||||
level_num: 11,
|
||||
is_primary: false,
|
||||
difficulty: "HYPER",
|
||||
data: JSON.stringify({ notecount: 80 }),
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const charts = await FindChartsOnPopularity("iidx", "SP", [9_000_890], 0, 100);
|
||||
|
||||
expect(charts.length).toBe(1);
|
||||
expect(charts[0]?.difficulty).toBe("HYPER");
|
||||
expect((charts[0]?.data as { "2dxtraSet"?: string | null })["2dxtraSet"] ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("does not apply the iidx 2dxtra song rule to other game groups", async () => {
|
||||
const sid = makeSongId(900);
|
||||
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: sid,
|
||||
legacy_id: 9_000_900,
|
||||
game_group: "sdvx",
|
||||
title: "Sdvx2dxtraKeyInJson",
|
||||
artist: "X",
|
||||
fts_document: "",
|
||||
data: JSON.stringify({}),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: makeChartId(900),
|
||||
legacy_id: "3".repeat(40),
|
||||
game: "sdvx",
|
||||
song_id: sid,
|
||||
level: "18",
|
||||
level_num: 18,
|
||||
is_primary: true,
|
||||
difficulty: "EXHAUST",
|
||||
data: JSON.stringify({ "2dxtraSet": "ignored-for-sdvx", notecount: 100 }),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const res = await SearchSongsForGameFtsAndTrgm("sdvx", "Sdvx2dxtraKeyInJson", 20);
|
||||
|
||||
expect(res.some((r) => r.legacy_id === 9_000_900)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchSongsForGameFtsAndTrgm (real seed subset)", () => {
|
||||
it.skipIf(!seedsJsonAvailable())(
|
||||
"finds known IIDX titles from a small songs-iidx slice",
|
||||
|
||||
@@ -10,6 +10,37 @@ export const MAX_SONG_SEARCH_RESULTS_PER_GAME = 100;
|
||||
/** Use trigram / ILIKE supplement when the query is this short or FTS returns nothing. */
|
||||
const SHORT_QUERY_LEN = 3;
|
||||
|
||||
/**
|
||||
* Queries this short match almost every row if we use substring `ILIKE '%q%'` / trgm.
|
||||
* For `len <=` this value we only add **exact** matches (title, artist, search_term, alt_title)
|
||||
* and skip substring trgm; FTS still runs first.
|
||||
*/
|
||||
export const SHORT_QUERY_STRICT_MAX_LEN = 2;
|
||||
|
||||
/**
|
||||
* IIDX-only: exclude songs whose **only** IIDX charts are 2dxtra (`data.2dxtraSet` set).
|
||||
* Songs with no `chart` rows yet, or with at least one non-2dxtra IIDX chart, stay searchable.
|
||||
*/
|
||||
function iidxSongSearchableWith2dxtraRuleSql(game: GameGroup) {
|
||||
if (game !== "iidx") {
|
||||
return sql`true`;
|
||||
}
|
||||
|
||||
return sql`(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM chart c
|
||||
WHERE c.song_id = song.id
|
||||
AND (c.game)::text LIKE 'iidx-%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM chart c
|
||||
WHERE c.song_id = song.id
|
||||
AND (c.game)::text LIKE 'iidx-%'
|
||||
AND (c.data->>'2dxtraSet') IS NULL
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
export type SongSearchRow = {
|
||||
artist: string;
|
||||
data: unknown;
|
||||
@@ -22,6 +53,10 @@ export type SongSearchRow = {
|
||||
/**
|
||||
* Indexed song search: PostgreSQL FTS (tsvector) plus optional pg_trgm / ILIKE fallback
|
||||
* (Zenith-style — no full-table load, no huge IN lists).
|
||||
*
|
||||
* Queries with length ≤ {@link SHORT_QUERY_STRICT_MAX_LEN} also run an **exact** match pass
|
||||
* (title, artist, `song_search_term`, `song_alt_title`) with a boosted rank; substring
|
||||
* `ILIKE '%q%'` trgm is skipped so single-letter titles like “A” are findable without noise.
|
||||
*/
|
||||
export async function SearchSongsForGameFtsAndTrgm(
|
||||
game: GameGroup,
|
||||
@@ -46,20 +81,73 @@ export async function SearchSongsForGameFtsAndTrgm(
|
||||
(ts_rank_cd(textsearch, websearch_to_tsquery('simple', ${q})))::float8 AS rank
|
||||
FROM song
|
||||
WHERE game_group = ${game}
|
||||
AND ${iidxSongSearchableWith2dxtraRuleSql(game)}
|
||||
AND textsearch @@ websearch_to_tsquery('simple', ${q})
|
||||
ORDER BY rank DESC
|
||||
LIMIT ${cap}
|
||||
`.execute(DB);
|
||||
|
||||
const needTrgm =
|
||||
ftsRows.length < cap && (q.length <= SHORT_QUERY_LEN || ftsRows.length === 0);
|
||||
const strictShort = q.length <= SHORT_QUERY_STRICT_MAX_LEN;
|
||||
|
||||
if (!needTrgm) {
|
||||
return ftsRows;
|
||||
const exactRows = strictShort
|
||||
? (
|
||||
await sql<SongSearchRow>`
|
||||
SELECT
|
||||
song.id,
|
||||
song.legacy_id,
|
||||
song.title,
|
||||
song.artist,
|
||||
song.data,
|
||||
10.0::float8 AS rank
|
||||
FROM song
|
||||
WHERE song.game_group = ${game}
|
||||
AND ${iidxSongSearchableWith2dxtraRuleSql(game)}
|
||||
AND (
|
||||
lower(song.title) = lower(${q})
|
||||
OR lower(song.artist) = lower(${q})
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM song_search_term st
|
||||
WHERE st.song_id = song.id AND lower(st.search_term) = lower(${q})
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM song_alt_title at
|
||||
WHERE at.song_id = song.id AND lower(at.alt_title) = lower(${q})
|
||||
)
|
||||
)
|
||||
LIMIT ${cap}
|
||||
`.execute(DB)
|
||||
).rows
|
||||
: [];
|
||||
|
||||
const mergedBeforeTrgm = new Map<string, SongSearchRow>();
|
||||
|
||||
for (const r of ftsRows) {
|
||||
mergedBeforeTrgm.set(r.id, r);
|
||||
}
|
||||
|
||||
const excludeIds = ftsRows.map((r) => r.id);
|
||||
const trgmLimit = cap - ftsRows.length;
|
||||
for (const r of exactRows) {
|
||||
const existing = mergedBeforeTrgm.get(r.id);
|
||||
|
||||
if (!existing || r.rank > existing.rank) {
|
||||
mergedBeforeTrgm.set(r.id, r);
|
||||
}
|
||||
}
|
||||
|
||||
const mergedList = [...mergedBeforeTrgm.values()].sort((a, b) => b.rank - a.rank);
|
||||
|
||||
const needTrgm =
|
||||
mergedList.length < cap &&
|
||||
!strictShort &&
|
||||
(q.length <= SHORT_QUERY_LEN || ftsRows.length === 0);
|
||||
|
||||
if (!needTrgm) {
|
||||
return mergedList.slice(0, cap);
|
||||
}
|
||||
|
||||
const excludeIds = mergedList.map((r) => r.id);
|
||||
const trgmLimit = cap - mergedList.length;
|
||||
const likeEsc = EscapeForILIKE(q.toLowerCase());
|
||||
const pat = `%${likeEsc}%`;
|
||||
|
||||
@@ -79,6 +167,7 @@ export async function SearchSongsForGameFtsAndTrgm(
|
||||
)::float8 AS rank
|
||||
FROM song
|
||||
WHERE game_group = ${game}
|
||||
AND ${iidxSongSearchableWith2dxtraRuleSql(game)}
|
||||
AND (
|
||||
title ILIKE ${pat}
|
||||
OR artist ILIKE ${pat}
|
||||
@@ -101,6 +190,7 @@ export async function SearchSongsForGameFtsAndTrgm(
|
||||
)::float8 AS rank
|
||||
FROM song
|
||||
WHERE game_group = ${game}
|
||||
AND ${iidxSongSearchableWith2dxtraRuleSql(game)}
|
||||
AND id NOT IN (${sql.join(excludeIds)})
|
||||
AND (
|
||||
title ILIKE ${pat}
|
||||
@@ -113,7 +203,7 @@ export async function SearchSongsForGameFtsAndTrgm(
|
||||
|
||||
const byId = new Map<string, SongSearchRow>();
|
||||
|
||||
for (const r of ftsRows) {
|
||||
for (const r of mergedList) {
|
||||
byId.set(r.id, r);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ import type { FilterQuery } from "mongodb";
|
||||
import type { ICollection } from "monk";
|
||||
|
||||
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
|
||||
import { LoadSongChildrenForPgIds, SearchSongsForGameFtsAndTrgm } from "#lib/db-formats/song-search";
|
||||
import {
|
||||
LoadSongChildrenForPgIds,
|
||||
MAX_SONG_SEARCH_RESULTS_PER_GAME,
|
||||
SearchSongsForGameFtsAndTrgm,
|
||||
} from "#lib/db-formats/song-search";
|
||||
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user";
|
||||
import { log } from "#lib/log/log";
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
@@ -15,7 +18,6 @@ import { GetOnlineCutoff } from "#utils/user";
|
||||
import { sql } from "kysely";
|
||||
import {
|
||||
type ChartDocument,
|
||||
CreateSongMap,
|
||||
type FolderDocument,
|
||||
type GameGroup,
|
||||
GamePTToV3,
|
||||
@@ -236,7 +238,9 @@ export async function SearchSpecificGameSongsAndCharts(
|
||||
return Promise.resolve([] as Array<ChartDocument>);
|
||||
}
|
||||
|
||||
return GetChartsBySongPgId(v3Game, pgId, song.id);
|
||||
return GetChartsBySongPgId(v3Game, pgId, song.id, {
|
||||
omit2dxtraCharts: game === "iidx",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -247,12 +251,15 @@ export async function SearchSpecificGameSongsAndCharts(
|
||||
|
||||
/**
|
||||
* Search this Game/GPTs songs and charts, but globally.
|
||||
*
|
||||
* Returns at most `limit` **chart** rows per GPT (same cap as song search). Without this,
|
||||
* N matched songs × charts per song could return a huge payload from `/api/v1/search`.
|
||||
*/
|
||||
export async function SearchGlobalGameSongsAndCharts(
|
||||
game: GameGroup,
|
||||
search: string,
|
||||
playtype?: Playtype,
|
||||
limit = 100,
|
||||
limit = MAX_SONG_SEARCH_RESULTS_PER_GAME,
|
||||
): Promise<Array<{ chart: ChartDocument; playcount: integer; song: SongDocument }>> {
|
||||
const { songs, pgIdByLegacyId } = await searchSpecificGameSongsWithPgIds(game, search, limit);
|
||||
|
||||
@@ -262,25 +269,42 @@ export async function SearchGlobalGameSongsAndCharts(
|
||||
|
||||
const v3Game = GamePTToV3(game, playtype);
|
||||
|
||||
const chartLists = await Promise.all(
|
||||
songs.map((song) => {
|
||||
const pgId = pgIdByLegacyId.get(song.id);
|
||||
const output: Array<{ chart: ChartDocument; playcount: integer; song: SongDocument }> = [];
|
||||
|
||||
if (!pgId) {
|
||||
return Promise.resolve([] as Array<ChartDocument>);
|
||||
for (const song of songs) {
|
||||
if (output.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pgId = pgIdByLegacyId.get(song.id);
|
||||
|
||||
if (!pgId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop -- stop after enough charts; avoids loading every song's charts when the cap is already reached.
|
||||
const songCharts = await GetChartsBySongPgId(v3Game, pgId, song.id, {
|
||||
omit2dxtraCharts: game === "iidx",
|
||||
});
|
||||
|
||||
for (const chart of songCharts) {
|
||||
if (output.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
return GetChartsBySongPgId(v3Game, pgId, song.id);
|
||||
}),
|
||||
);
|
||||
output.push({
|
||||
song,
|
||||
chart,
|
||||
playcount: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const charts = chartLists.flat();
|
||||
|
||||
if (charts.length === 0) {
|
||||
if (output.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chartLegacyIds = charts.map((c) => c.chartID);
|
||||
const chartLegacyIds = output.map((o) => o.chart.chartID);
|
||||
|
||||
const playcountRows = await DB.selectFrom("score")
|
||||
.innerJoin("chart", "chart.id", "score.chart_id")
|
||||
@@ -291,24 +315,8 @@ export async function SearchGlobalGameSongsAndCharts(
|
||||
|
||||
const playcountLookup = Object.fromEntries(playcountRows.map((r) => [r.legacy_id, r.playcount]));
|
||||
|
||||
const songMap = CreateSongMap(songs);
|
||||
|
||||
const output = [];
|
||||
|
||||
for (const chart of charts) {
|
||||
const song = songMap.get(chart.songID);
|
||||
|
||||
if (!song) {
|
||||
log.warn(`Failed to find parent song for ${chart.songID} (${game})? Skipping.`);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
song,
|
||||
chart,
|
||||
playcount: playcountLookup[chart.chartID] ?? 0,
|
||||
});
|
||||
for (const row of output) {
|
||||
row.playcount = playcountLookup[row.chart.chartID] ?? 0;
|
||||
}
|
||||
|
||||
return output;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Game } from "tachi-db";
|
||||
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { sql } from "kysely";
|
||||
import { sql, type SqlBool } from "kysely";
|
||||
import {
|
||||
type ChartDocument,
|
||||
type ChartDocumentData,
|
||||
@@ -252,7 +252,8 @@ export async function FindChartsOnPopularity(
|
||||
let q = DB.selectFrom("chart")
|
||||
.innerJoin("song", "song.id", "chart.song_id")
|
||||
.leftJoin("score", "score.chart_id", "chart.id")
|
||||
.where("chart.game", "=", v3Game as Game);
|
||||
.where("chart.game", "=", v3Game as Game)
|
||||
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`);
|
||||
|
||||
if (songIDs && songIDs.length > 0) {
|
||||
q = q.where("song.legacy_id", "in", songIDs);
|
||||
|
||||
Reference in New Issue
Block a user