From 6ed72052fcc9c2c8279d13cd9411a773134058d2 Mon Sep 17 00:00:00 2001 From: zk Date: Sat, 28 Mar 2026 13:14:34 +0000 Subject: [PATCH] feat: second half of search functionality --- typescript/server/src/lib/db-formats/chart.ts | 32 +- .../src/lib/db-formats/song-search.test.ts | 411 +++++++++++++++++- .../server/src/lib/db-formats/song-search.ts | 104 ++++- typescript/server/src/lib/search/search.ts | 78 ++-- typescript/server/src/utils/queries/charts.ts | 5 +- 5 files changed, 572 insertions(+), 58 deletions(-) diff --git a/typescript/server/src/lib/db-formats/chart.ts b/typescript/server/src/lib/db-formats/chart.ts index db6f404e8..6c34dd941 100644 --- a/typescript/server/src/lib/db-formats/chart.ts +++ b/typescript/server/src/lib/db-formats/chart.ts @@ -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 { - 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`(data->>'2dxtraSet') IS NULL`); + } + + const rows = await q.execute(); if (rows.length === 0) { return []; diff --git a/typescript/server/src/lib/db-formats/song-search.test.ts b/typescript/server/src/lib/db-formats/song-search.test.ts index 7ad8e26f4..64490d3d8 100644 --- a/typescript/server/src/lib/db-formats/song-search.test.ts +++ b/typescript/server/src/lib/db-formats/song-search.test.ts @@ -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 { 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)["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", diff --git a/typescript/server/src/lib/db-formats/song-search.ts b/typescript/server/src/lib/db-formats/song-search.ts index 34c450645..c18d02f7f 100644 --- a/typescript/server/src/lib/db-formats/song-search.ts +++ b/typescript/server/src/lib/db-formats/song-search.ts @@ -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` + 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(); + + 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(); - for (const r of ftsRows) { + for (const r of mergedList) { byId.set(r.id, r); } diff --git a/typescript/server/src/lib/search/search.ts b/typescript/server/src/lib/search/search.ts index fb4e60313..e1f163ead 100644 --- a/typescript/server/src/lib/search/search.ts +++ b/typescript/server/src/lib/search/search.ts @@ -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); } - 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> { 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); + 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; diff --git a/typescript/server/src/utils/queries/charts.ts b/typescript/server/src/utils/queries/charts.ts index b59e3e824..7aaae840d 100644 --- a/typescript/server/src/utils/queries/charts.ts +++ b/typescript/server/src/utils/queries/charts.ts @@ -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`(chart.data->>'2dxtraSet') IS NULL`); if (songIDs && songIDs.length > 0) { q = q.where("song.legacy_id", "in", songIDs);