feat(search): server-side offset pagination through providers + /search route (#115 backend)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-07-04 14:36:54 +08:00
1 parent 81b8953d52
commit a1cc0b8574
14 files changed
+322 -34

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from "vitest";
import { BiliBiliProvider } from "./bilibili.js";
describe("BiliBiliProvider.search pagination", () => {
function mockProvider() {
const p = new BiliBiliProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { result: [] } } });
// Short-circuit the buvid + wbi bootstrap so search only issues the
// /search/type request we want to inspect.
(p as any).buvidInitialized = true;
(p as any).wbiMixinKey = "0".repeat(32);
(p as any).wbiKeyFetchedAt = Date.now();
(p as any).api = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls.find(
(c: any[]) => c[0] === "/x/web-interface/wbi/search/type"
);
expect(call, "expected a /search/type call").toBeTruthy();
// signWbi stringifies every value.
return call![1].params as Record<string, string>;
}
it("adds page (offset/limit+1) alongside page_size", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe("2");
expect(params.page_size).toBe("20");
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe("1");
});
});
+5 -1
View File
@@ -147,12 +147,16 @@ export class BiliBiliProvider implements MusicProvider {
return fixed; return fixed;
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
await this.ensureBuvidCookie(); await this.ensureBuvidCookie();
await this.ensureWbiKeys(); await this.ensureWbiKeys();
// /search/type is page-based; the web pages in limit-aligned steps so
// offset is a multiple of page_size.
const page = Math.floor(offset / limit) + 1;
const signed = this.signWbi({ const signed = this.signWbi({
search_type: "video", search_type: "video",
keyword: query, keyword: query,
page,
page_size: limit, page_size: limit,
}); });
const res = await this.api.get("/x/web-interface/wbi/search/type", { const res = await this.api.get("/x/web-interface/wbi/search/type", {
+31 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc } from "./kugou.js"; import { mapKugouSong, mapKugouSongs, mapKugouAlbums, mapKugouPlaylist, mapKugouPlaylists, krcToLrc, KugouProvider } from "./kugou.js";
import { parseLyrics } from "./netease.js"; import { parseLyrics } from "./netease.js";
describe("mapKugouSongs", () => { describe("mapKugouSongs", () => {
@@ -193,3 +193,32 @@ describe("mapKugouPlaylists", () => {
expect(mapKugouPlaylists(undefined)).toEqual([]); expect(mapKugouPlaylists(undefined)).toEqual([]);
}); });
}); });
describe("KugouProvider.search pagination", () => {
function mockProvider() {
const p = new KugouProvider();
const get = vi.fn().mockResolvedValue({ data: { data: { info: [] } } });
(p as any).mobileHttp = { get };
return { p, get };
}
function searchParams(get: ReturnType<typeof vi.fn>) {
const call = get.mock.calls[0];
expect(call, "expected a mobile search call").toBeTruthy();
return call[1].params as Record<string, unknown>;
}
it("sets page to offset/limit+1 and keeps pagesize=limit", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20); // page 2
const params = searchParams(get);
expect(params.page).toBe(2);
expect(params.pagesize).toBe(20);
});
it("defaults offset to 0 → page 1 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(searchParams(get).page).toBe(1);
});
});
+5 -2
View File
@@ -616,12 +616,15 @@ export class KugouProvider implements MusicProvider {
} }
// --- Search (verified live via the unsigned mobile endpoint) --------------- // --- Search (verified live via the unsigned mobile endpoint) ---------------
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim(); const q = query.trim();
if (!q) return { songs: [], playlists: [], albums: [] }; if (!q) return { songs: [], playlists: [], albums: [] };
try { try {
// Songs only. `page` is the 1-based cursor; the web pages in limit-aligned
// steps so offset is a multiple of pagesize.
const page = Math.floor(offset / limit) + 1;
const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", { const res = await this.mobileHttp.get("http://mobilecdn.kugou.com/api/v3/search/song", {
params: { format: "json", keyword: q, page: 1, pagesize: limit, showtype: 1 }, params: { format: "json", keyword: q, page, pagesize: limit, showtype: 1 },
}); });
const info = res.data?.data?.info as KugouRawSong[] | undefined; const info = res.data?.data?.info as KugouRawSong[] | undefined;
return { songs: mapKugouSongs(info), playlists: [], albums: [] }; return { songs: mapKugouSongs(info), playlists: [], albums: [] };
+14
View File
@@ -205,6 +205,20 @@ describe("LocalMusicProvider quota", () => {
}); });
}); });
describe("LocalMusicProvider search pagination", () => {
it("slices [offset, offset+limit) instead of the first page", async () => {
const recs = ["a", "b", "c", "d"].map((id) => makeRecord(id));
seed(recs); // newest-first order preserved: a, b, c, d
const p = new LocalMusicProvider(dir);
const page1 = await p.search("", 2); // offset defaults to 0
expect(page1.songs.map((s) => s.id)).toEqual(["a", "b"]);
const page2 = await p.search("", 2, 2);
expect(page2.songs.map((s) => s.id)).toEqual(["c", "d"]);
});
});
describe("LocalMusicProvider filename handling", () => { describe("LocalMusicProvider filename handling", () => {
it("accepts a long filename without dropping its extension", async () => { it("accepts a long filename without dropping its extension", async () => {
const p = new LocalMusicProvider(dir); const p = new LocalMusicProvider(dir);
+2 -2
View File
@@ -214,12 +214,12 @@ export class LocalMusicProvider implements MusicProvider {
return song; return song;
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const songs = this.records const songs = this.records
.filter((r) => existsSync(r.filePath)) .filter((r) => existsSync(r.filePath))
.filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q)) .filter((r) => !q || `${r.name} ${r.artist} ${r.album} ${r.originalName}`.toLowerCase().includes(q))
.slice(0, limit) .slice(offset, offset + limit)
.map((r) => this.toSong(r)); .map((r) => this.toSong(r));
return { songs, playlists: [], albums: [] }; return { songs, playlists: [], albums: [] };
} }
+48 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial } from "./netease.js"; import { parseLyrics, mapNeteaseAlbums, mapNeteaseSongs, parseNeteaseTrial, NeteaseProvider } from "./netease.js";
describe("NetEase adapter", () => { describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => { it("parses LRC format lyrics", () => {
@@ -93,3 +93,49 @@ describe("NetEase adapter", () => {
expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined(); expect(parseNeteaseTrial({ freeTrialInfo: { start: 0, end: 0 } })).toBeUndefined();
}); });
}); });
describe("NeteaseProvider.search pagination", () => {
function mockProvider() {
const p = new NeteaseProvider("http://x");
const get = vi.fn().mockResolvedValue({
data: { result: { songs: [], playlists: [], albums: [] } },
});
(p as any).api = { get };
return { p, get };
}
/** Find the /cloudsearch call whose params.type matches. */
function callByType(get: ReturnType<typeof vi.fn>, type: number) {
const call = get.mock.calls.find((c: any[]) => c[1]?.params?.type === type);
expect(call, `expected a /cloudsearch call with type=${type}`).toBeTruthy();
return call![1].params as Record<string, unknown>;
}
it("forwards offset for songs and uses real limit+offset for playlists/albums", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20, 20);
// songs (type 1): offset forwarded, limit unchanged
const songs = callByType(get, 1);
expect(songs.limit).toBe(20);
expect(songs.offset).toBe(20);
// playlists (type 1000): limit-driven (NOT hardcoded 10) + offset
const playlists = callByType(get, 1000);
expect(playlists.limit).toBe(20);
expect(playlists.offset).toBe(20);
// albums (type 10): limit-driven (NOT hardcoded 10) + offset
const albums = callByType(get, 10);
expect(albums.limit).toBe(20);
expect(albums.offset).toBe(20);
});
it("defaults offset to 0 (backward compatible)", async () => {
const { p, get } = mockProvider();
await p.search("hello", 20);
expect(callByType(get, 1).offset).toBe(0);
expect(callByType(get, 1000).offset).toBe(0);
expect(callByType(get, 10).offset).toBe(0);
});
});
+8 -4
View File
@@ -131,21 +131,25 @@ export class NeteaseProvider implements MusicProvider {
return this.cookie ? { cookie: this.cookie } : {}; return this.cookie ? { cookie: this.cookie } : {};
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// /cloudsearch supports offset for every type. Songs, playlists (type 1000)
// and albums (type 10) are all limit/offset-driven so the web can page past
// the first page (playlists/albums were previously hardcoded to limit: 10).
const [songRes, playlistRes, albumRes] = await Promise.all([ const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams }, params: { keywords: query, type: 1, limit, offset, ...this.cookieParams },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { params: {
keywords: query, keywords: query,
type: 1000, type: 1000,
limit: 10, limit,
offset,
...this.cookieParams, ...this.cookieParams,
}, },
}), }),
this.api.get("/cloudsearch", { this.api.get("/cloudsearch", {
params: { keywords: query, type: 10, limit: 10, ...this.cookieParams }, params: { keywords: query, type: 10, limit, offset, ...this.cookieParams },
}), }),
]); ]);
+1 -1
View File
@@ -74,7 +74,7 @@ export interface AuthStatus {
export interface MusicProvider { export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"; readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
search(query: string, limit?: number): Promise<SearchResult>; search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>; getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
setQuality(quality: string): void; setQuality(quality: string): void;
getQuality(): string; getQuality(): string;
+82 -2
View File
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { mapQqAlbums, mapQqSongs, parseQqTrial } from "./qq.js";
// All axios.create(...) instances in qq.ts (qqMusicuApi / qqSearchApi / qqFavApi
// and the per-instance api) share this single mock so the search test can
// inspect the outgoing params/body regardless of which client issued them.
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
vi.mock("axios", () => ({
default: { create: () => ({ get: mockGet, post: mockPost }) },
}));
import { mapQqAlbums, mapQqSongs, parseQqTrial, QQMusicProvider } from "./qq.js";
describe("QQ adapter", () => { describe("QQ adapter", () => {
it("mapQqSongs maps QQMusicApi-style song entries", () => { it("mapQqSongs maps QQMusicApi-style song entries", () => {
@@ -92,3 +101,74 @@ describe("QQ adapter", () => {
expect(out[0].id).toBe(""); expect(out[0].id).toBe("");
}); });
}); });
describe("QQMusicProvider.search pagination", () => {
beforeEach(() => {
mockGet.mockReset();
mockPost.mockReset();
});
/** musicu.fcg returns one song → primary path succeeds. */
function musicuOk() {
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return {
data: {
req_0: { data: { body: { song: { list: [{ mid: "m1", name: "S", singer: [], album: {}, interval: 100 }] } } } },
req_album: { data: { body: { album: { list: [] } } } },
req_playlist: { data: { body: { songlist: { list: [] } } } },
},
};
}
return { data: {} };
});
}
function musicuReqData() {
const call = mockGet.mock.calls.find((c: any[]) => c[0] === "/cgi-bin/musicu.fcg");
expect(call, "expected a musicu.fcg call").toBeTruthy();
return JSON.parse(call![1].params.data);
}
it("adds page_num (offset/limit+1) and limit-driven num_per_page for songs/albums/playlists", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(2);
expect(d.req_0.param.num_per_page).toBe(20);
// Albums/playlists: num_per_page must be limit-driven (NOT hardcoded 10).
expect(d.req_album.param.page_num).toBe(2);
expect(d.req_album.param.num_per_page).toBe(20);
expect(d.req_playlist.param.page_num).toBe(2);
expect(d.req_playlist.param.num_per_page).toBe(20);
});
it("defaults offset to 0 → page_num 1 (backward compatible)", async () => {
musicuOk();
const p = new QQMusicProvider("http://x");
await p.search("hello", 20);
const d = musicuReqData();
expect(d.req_0.param.page_num).toBe(1);
});
it("fallback client_search_cp sets p to the page cursor", async () => {
// musicu returns no songs → primary returns null → fallback runs.
mockGet.mockImplementation(async (url: string) => {
if (url === "/cgi-bin/musicu.fcg") {
return { data: { req_0: { data: { body: { song: { list: [] } } } } } };
}
// client_search_cp
return { data: { data: { song: { list: [] }, album: { list: [] } } } };
});
const p = new QQMusicProvider("http://x");
await p.search("hello", 20, 20); // page 2
const songCall = mockGet.mock.calls.find(
(c: any[]) => c[0] === "/soso/fcgi-bin/client_search_cp" && c[1]?.params?.type === 0
);
expect(songCall, "expected a client_search_cp song call").toBeTruthy();
expect(songCall![1].params.p).toBe(2);
});
});
+20 -10
View File
@@ -149,16 +149,16 @@ export class QQMusicProvider implements MusicProvider {
}; };
} }
async search(query: string, limit = 20): Promise<SearchResult> { async search(query: string, limit = 20, offset = 0): Promise<SearchResult> {
// Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums + // Primary: u.y.qq.com/cgi-bin/musicu.fcg — supports songs + albums +
// playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61 // playlists. Fixed per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61
// (removed searchid, num_per_page >= 10, corrected search_type values). // (removed searchid, num_per_page >= 10, corrected search_type values).
const primary = await this.searchViaMusicuFcg(query, limit); const primary = await this.searchViaMusicuFcg(query, limit, offset);
if (primary) return primary; if (primary) return primary;
// Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album, // Fallback: c.y.qq.com/soso/fcgi-bin/client_search_cp (song + album,
// no playlist support). Kept as redundancy. // no playlist support). Kept as redundancy.
return this.searchViaClientSearchCp(query, limit); return this.searchViaClientSearchCp(query, limit, offset);
} }
/** Primary search via u.y.qq.com/cgi-bin/musicu.fcg. /** Primary search via u.y.qq.com/cgi-bin/musicu.fcg.
@@ -169,25 +169,31 @@ export class QQMusicProvider implements MusicProvider {
* 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */ * 3. `search_type: 2` for albums, `3` for playlists (8 was "user"). */
private async searchViaMusicuFcg( private async searchViaMusicuFcg(
query: string, query: string,
limit: number limit: number,
offset = 0
): Promise<SearchResult | null> { ): Promise<SearchResult | null> {
try { try {
// num_per_page must stay >= 10 (lower values return empty). It is now
// limit-driven for ALL three lists (albums/playlists were hardcoded to
// 10). page_num is the offset cursor; the web always requests in
// limit-aligned pages so offset is a multiple of limit.
const numPerPage = Math.max(10, Math.min(limit, 50)); const numPerPage = Math.max(10, Math.min(limit, 50));
const pageNum = Math.floor(offset / limit) + 1;
const reqData = JSON.stringify({ const reqData = JSON.stringify({
req_0: { req_0: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: numPerPage, search_type: 0 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 0 },
}, },
req_album: { req_album: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 2 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 2 },
}, },
req_playlist: { req_playlist: {
module: "music.search.SearchCgiService", module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop", method: "DoSearchForQQMusicDesktop",
param: { query, num_per_page: 10, search_type: 3 }, param: { query, num_per_page: numPerPage, page_num: pageNum, search_type: 3 },
}, },
}); });
const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", { const res = await qqMusicuApi.get("/cgi-bin/musicu.fcg", {
@@ -221,12 +227,16 @@ export class QQMusicProvider implements MusicProvider {
/** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */ /** Fallback search via c.y.qq.com/soso/fcgi-bin/client_search_cp */
private async searchViaClientSearchCp( private async searchViaClientSearchCp(
query: string, query: string,
limit: number limit: number,
offset = 0
): Promise<SearchResult> { ): Promise<SearchResult> {
// `p` is the 1-based page cursor. The web pages in limit-aligned steps so
// offset is a multiple of limit.
const page = Math.floor(offset / limit) + 1;
const songParams = { const songParams = {
w: query, w: query,
format: "json", format: "json",
p: 1, p: page,
n: Math.min(limit, 50), n: Math.min(limit, 50),
type: 0, type: 0,
cr: 1, cr: 1,
@@ -234,7 +244,7 @@ export class QQMusicProvider implements MusicProvider {
const albumParams = { const albumParams = {
w: query, w: query,
format: "json", format: "json",
p: 1, p: page,
n: 5, n: 5,
t: 8, t: 8,
cr: 1, cr: 1,
+12 -6
View File
@@ -115,20 +115,26 @@ export class YouTubeProvider implements MusicProvider {
readonly platform = "youtube" as const; readonly platform = "youtube" as const;
private quality = "bestaudio"; private quality = "bestaudio";
async search(query: string, limit = 5): Promise<SearchResult> { async search(query: string, limit = 5, offset = 0): Promise<SearchResult> {
try { try {
// yt-dlp's `ytsearchN` has no offset cursor — it always returns the first
// N results. Best-effort paginate by fetching offset+limit and slicing
// locally. (offset 0 → identical to before.)
const total = offset + limit;
const raw = await runYtDlp([ const raw = await runYtDlp([
`ytsearch${limit}:${query}`, `ytsearch${total}:${query}`,
"--dump-json", "--dump-json",
"--flat-playlist", "--flat-playlist",
"--no-warnings", "--no-warnings",
"--quiet", "--quiet",
]); ]);
const lines = raw.trim().split("\n").filter(Boolean); const lines = raw.trim().split("\n").filter(Boolean);
const songs: Song[] = lines.map((line) => { const songs: Song[] = lines
const entry = JSON.parse(line) as YtDlpEntry; .slice(offset, offset + limit)
return entryToSong(entry); .map((line) => {
}); const entry = JSON.parse(line) as YtDlpEntry;
return entryToSong(entry);
});
return { songs, playlists: [], albums: [] }; return { songs, playlists: [], albums: [] };
} catch { } catch {
return { songs: [], playlists: [], albums: [] }; return { songs: [], playlists: [], albums: [] };
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import type { MusicProvider, SearchResult } from "../../music/provider.js";
import { createMusicRouter } from "./music.js";
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
return {
platform,
search: vi.fn().mockResolvedValue(empty),
} as unknown as MusicProvider;
}
describe("music router GET /search offset pagination", () => {
let app: express.Express;
let netease: MusicProvider;
beforeEach(() => {
netease = fakeProvider("netease");
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
app = express();
app.use("/api/music", router);
});
it("parses offset and passes it as the 3rd arg to provider.search", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 20);
});
it("defaults a missing offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("clamps a negative offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=-5");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
});
+6 -2
View File
@@ -84,7 +84,7 @@ export function createMusicRouter(
router.get("/search", async (req, res) => { router.get("/search", async (req, res) => {
try { try {
const { q, platform, limit } = req.query; const { q, platform, limit, offset } = req.query;
if (!q) { if (!q) {
res.status(400).json({ error: "q (query) is required" }); res.status(400).json({ error: "q (query) is required" });
return; return;
@@ -94,9 +94,13 @@ export function createMusicRouter(
return; return;
} }
const provider = getProvider(platform as string); const provider = getProvider(platform as string);
// Server-side pagination: offset lets the web load past the first page.
// Clamp to >= 0 so a bad/negative value falls back to the first page.
const parsedOffset = Math.max(0, parseInt(offset as string) || 0);
const result = await provider.search( const result = await provider.search(
q as string, q as string,
parseInt(limit as string) || 20 parseInt(limit as string) || 20,
parsedOffset
); );
res.json(result); res.json(result);
} catch (err) { } catch (err) {