From 40166b0901c69d727f4576bc30a51ca66457ace0 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:43:57 -0400 Subject: [PATCH 01/15] feat: add album-record-store content template Made-with: Cursor --- lib/content/contentTemplates.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/content/contentTemplates.ts b/lib/content/contentTemplates.ts index 7d0653e3..179c7453 100644 --- a/lib/content/contentTemplates.ts +++ b/lib/content/contentTemplates.ts @@ -20,6 +20,11 @@ export const CONTENT_TEMPLATES: ContentTemplate[] = [ description: "Small venue concert", defaultLipsync: false, }, + { + name: "album-record-store", + description: "Album art on vinyl in a NYC record store", + defaultLipsync: false, + }, ]; /** Derived from the first entry in CONTENT_TEMPLATES to avoid string duplication. */ From 200bf73f6dd7837ebf54d00329c852a34f03f32d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:28:50 -0400 Subject: [PATCH 02/15] feat: add 20 research API endpoints backed by Chartmetric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20 flat routes under /api/research/ with query params (matching Recoup API pattern). Shared infrastructure: - getChartmetricToken: token exchange - proxyToChartmetric: auth + proxy + strip obj wrapper - resolveArtist: name/UUID → Chartmetric ID resolution - handleArtistResearch: DRY handler for artist-scoped endpoints Endpoints: search, lookup, profile, metrics, audience, cities, similar, urls, instagram-posts, playlists, albums, tracks, career, insights, track, playlist, curator, discover, genres, festivals. 17 tests passing (token: 4, proxy: 6, resolve: 7). Web/deep research endpoints deferred to separate PR. Made-with: Cursor --- app/api/research/albums/route.ts | 18 ++++ app/api/research/audience/route.ts | 18 ++++ app/api/research/career/route.ts | 18 ++++ app/api/research/cities/route.ts | 18 ++++ app/api/research/curator/route.ts | 18 ++++ app/api/research/discover/route.ts | 18 ++++ app/api/research/festivals/route.ts | 18 ++++ app/api/research/genres/route.ts | 18 ++++ app/api/research/insights/route.ts | 18 ++++ app/api/research/instagram-posts/route.ts | 18 ++++ app/api/research/lookup/route.ts | 18 ++++ app/api/research/metrics/route.ts | 18 ++++ app/api/research/playlist/route.ts | 18 ++++ app/api/research/playlists/route.ts | 18 ++++ app/api/research/profile/route.ts | 18 ++++ app/api/research/route.ts | 21 +++++ app/api/research/similar/route.ts | 18 ++++ app/api/research/track/route.ts | 18 ++++ app/api/research/tracks/route.ts | 18 ++++ app/api/research/urls/route.ts | 18 ++++ .../__tests__/getChartmetricToken.test.ts | 60 ++++++++++++ lib/chartmetric/getChartmetricToken.ts | 33 +++++++ .../__tests__/proxyToChartmetric.test.ts | 93 +++++++++++++++++++ lib/research/__tests__/resolveArtist.test.ts | 81 ++++++++++++++++ lib/research/getResearchAlbumsHandler.ts | 14 +++ lib/research/getResearchAudienceHandler.ts | 18 ++++ lib/research/getResearchCareerHandler.ts | 14 +++ lib/research/getResearchCitiesHandler.ts | 14 +++ lib/research/getResearchCuratorHandler.ts | 57 ++++++++++++ lib/research/getResearchDiscoverHandler.ts | 73 +++++++++++++++ lib/research/getResearchFestivalsHandler.ts | 46 +++++++++ lib/research/getResearchGenresHandler.ts | 47 ++++++++++ lib/research/getResearchInsightsHandler.ts | 15 +++ .../getResearchInstagramPostsHandler.ts | 15 +++ lib/research/getResearchLookupHandler.ts | 70 ++++++++++++++ lib/research/getResearchMetricsHandler.ts | 26 ++++++ lib/research/getResearchPlaylistHandler.ts | 57 ++++++++++++ lib/research/getResearchPlaylistsHandler.ts | 39 ++++++++ lib/research/getResearchProfileHandler.ts | 14 +++ lib/research/getResearchSearchHandler.ts | 56 +++++++++++ lib/research/getResearchSimilarHandler.ts | 38 ++++++++ lib/research/getResearchTrackHandler.ts | 82 ++++++++++++++++ lib/research/getResearchTracksHandler.ts | 14 +++ lib/research/getResearchUrlsHandler.ts | 15 +++ lib/research/handleArtistResearch.ts | 76 +++++++++++++++ lib/research/proxyToChartmetric.ts | 53 +++++++++++ lib/research/resolveArtist.ts | 53 +++++++++++ 47 files changed, 1536 insertions(+) create mode 100644 app/api/research/albums/route.ts create mode 100644 app/api/research/audience/route.ts create mode 100644 app/api/research/career/route.ts create mode 100644 app/api/research/cities/route.ts create mode 100644 app/api/research/curator/route.ts create mode 100644 app/api/research/discover/route.ts create mode 100644 app/api/research/festivals/route.ts create mode 100644 app/api/research/genres/route.ts create mode 100644 app/api/research/insights/route.ts create mode 100644 app/api/research/instagram-posts/route.ts create mode 100644 app/api/research/lookup/route.ts create mode 100644 app/api/research/metrics/route.ts create mode 100644 app/api/research/playlist/route.ts create mode 100644 app/api/research/playlists/route.ts create mode 100644 app/api/research/profile/route.ts create mode 100644 app/api/research/route.ts create mode 100644 app/api/research/similar/route.ts create mode 100644 app/api/research/track/route.ts create mode 100644 app/api/research/tracks/route.ts create mode 100644 app/api/research/urls/route.ts create mode 100644 lib/chartmetric/__tests__/getChartmetricToken.test.ts create mode 100644 lib/chartmetric/getChartmetricToken.ts create mode 100644 lib/research/__tests__/proxyToChartmetric.test.ts create mode 100644 lib/research/__tests__/resolveArtist.test.ts create mode 100644 lib/research/getResearchAlbumsHandler.ts create mode 100644 lib/research/getResearchAudienceHandler.ts create mode 100644 lib/research/getResearchCareerHandler.ts create mode 100644 lib/research/getResearchCitiesHandler.ts create mode 100644 lib/research/getResearchCuratorHandler.ts create mode 100644 lib/research/getResearchDiscoverHandler.ts create mode 100644 lib/research/getResearchFestivalsHandler.ts create mode 100644 lib/research/getResearchGenresHandler.ts create mode 100644 lib/research/getResearchInsightsHandler.ts create mode 100644 lib/research/getResearchInstagramPostsHandler.ts create mode 100644 lib/research/getResearchLookupHandler.ts create mode 100644 lib/research/getResearchMetricsHandler.ts create mode 100644 lib/research/getResearchPlaylistHandler.ts create mode 100644 lib/research/getResearchPlaylistsHandler.ts create mode 100644 lib/research/getResearchProfileHandler.ts create mode 100644 lib/research/getResearchSearchHandler.ts create mode 100644 lib/research/getResearchSimilarHandler.ts create mode 100644 lib/research/getResearchTrackHandler.ts create mode 100644 lib/research/getResearchTracksHandler.ts create mode 100644 lib/research/getResearchUrlsHandler.ts create mode 100644 lib/research/handleArtistResearch.ts create mode 100644 lib/research/proxyToChartmetric.ts create mode 100644 lib/research/resolveArtist.ts diff --git a/app/api/research/albums/route.ts b/app/api/research/albums/route.ts new file mode 100644 index 00000000..d31fd486 --- /dev/null +++ b/app/api/research/albums/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchAlbumsHandler } from "@/lib/research/getResearchAlbumsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchAlbumsHandler(request); +} diff --git a/app/api/research/audience/route.ts b/app/api/research/audience/route.ts new file mode 100644 index 00000000..c1bbc0ab --- /dev/null +++ b/app/api/research/audience/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchAudienceHandler } from "@/lib/research/getResearchAudienceHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchAudienceHandler(request); +} diff --git a/app/api/research/career/route.ts b/app/api/research/career/route.ts new file mode 100644 index 00000000..97ba0a86 --- /dev/null +++ b/app/api/research/career/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCareerHandler } from "@/lib/research/getResearchCareerHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchCareerHandler(request); +} diff --git a/app/api/research/cities/route.ts b/app/api/research/cities/route.ts new file mode 100644 index 00000000..54c0d8fb --- /dev/null +++ b/app/api/research/cities/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCitiesHandler } from "@/lib/research/getResearchCitiesHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchCitiesHandler(request); +} diff --git a/app/api/research/curator/route.ts b/app/api/research/curator/route.ts new file mode 100644 index 00000000..f91980dd --- /dev/null +++ b/app/api/research/curator/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCuratorHandler } from "@/lib/research/getResearchCuratorHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchCuratorHandler(request); +} diff --git a/app/api/research/discover/route.ts b/app/api/research/discover/route.ts new file mode 100644 index 00000000..044414df --- /dev/null +++ b/app/api/research/discover/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchDiscoverHandler } from "@/lib/research/getResearchDiscoverHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchDiscoverHandler(request); +} diff --git a/app/api/research/festivals/route.ts b/app/api/research/festivals/route.ts new file mode 100644 index 00000000..d5a45e45 --- /dev/null +++ b/app/api/research/festivals/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchFestivalsHandler } from "@/lib/research/getResearchFestivalsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchFestivalsHandler(request); +} diff --git a/app/api/research/genres/route.ts b/app/api/research/genres/route.ts new file mode 100644 index 00000000..8db1a0f5 --- /dev/null +++ b/app/api/research/genres/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchGenresHandler } from "@/lib/research/getResearchGenresHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchGenresHandler(request); +} diff --git a/app/api/research/insights/route.ts b/app/api/research/insights/route.ts new file mode 100644 index 00000000..89383290 --- /dev/null +++ b/app/api/research/insights/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchInsightsHandler } from "@/lib/research/getResearchInsightsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchInsightsHandler(request); +} diff --git a/app/api/research/instagram-posts/route.ts b/app/api/research/instagram-posts/route.ts new file mode 100644 index 00000000..983afc7d --- /dev/null +++ b/app/api/research/instagram-posts/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchInstagramPostsHandler } from "@/lib/research/getResearchInstagramPostsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchInstagramPostsHandler(request); +} diff --git a/app/api/research/lookup/route.ts b/app/api/research/lookup/route.ts new file mode 100644 index 00000000..573c4d7e --- /dev/null +++ b/app/api/research/lookup/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchLookupHandler } from "@/lib/research/getResearchLookupHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchLookupHandler(request); +} diff --git a/app/api/research/metrics/route.ts b/app/api/research/metrics/route.ts new file mode 100644 index 00000000..8ca46aee --- /dev/null +++ b/app/api/research/metrics/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMetricsHandler } from "@/lib/research/getResearchMetricsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchMetricsHandler(request); +} diff --git a/app/api/research/playlist/route.ts b/app/api/research/playlist/route.ts new file mode 100644 index 00000000..95955bab --- /dev/null +++ b/app/api/research/playlist/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchPlaylistHandler } from "@/lib/research/getResearchPlaylistHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchPlaylistHandler(request); +} diff --git a/app/api/research/playlists/route.ts b/app/api/research/playlists/route.ts new file mode 100644 index 00000000..d8d67d3c --- /dev/null +++ b/app/api/research/playlists/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchPlaylistsHandler } from "@/lib/research/getResearchPlaylistsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchPlaylistsHandler(request); +} diff --git a/app/api/research/profile/route.ts b/app/api/research/profile/route.ts new file mode 100644 index 00000000..a21b6417 --- /dev/null +++ b/app/api/research/profile/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchProfileHandler } from "@/lib/research/getResearchProfileHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchProfileHandler(request); +} diff --git a/app/api/research/route.ts b/app/api/research/route.ts new file mode 100644 index 00000000..b7d7e175 --- /dev/null +++ b/app/api/research/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchSearchHandler } from "@/lib/research/getResearchSearchHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research + * + * Search for artists by name. + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchSearchHandler(request); +} diff --git a/app/api/research/similar/route.ts b/app/api/research/similar/route.ts new file mode 100644 index 00000000..25f0e1ab --- /dev/null +++ b/app/api/research/similar/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchSimilarHandler } from "@/lib/research/getResearchSimilarHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchSimilarHandler(request); +} diff --git a/app/api/research/track/route.ts b/app/api/research/track/route.ts new file mode 100644 index 00000000..a2c86eae --- /dev/null +++ b/app/api/research/track/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTrackHandler } from "@/lib/research/getResearchTrackHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchTrackHandler(request); +} diff --git a/app/api/research/tracks/route.ts b/app/api/research/tracks/route.ts new file mode 100644 index 00000000..adcfc7db --- /dev/null +++ b/app/api/research/tracks/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTracksHandler } from "@/lib/research/getResearchTracksHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchTracksHandler(request); +} diff --git a/app/api/research/urls/route.ts b/app/api/research/urls/route.ts new file mode 100644 index 00000000..1a74054f --- /dev/null +++ b/app/api/research/urls/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchUrlsHandler } from "@/lib/research/getResearchUrlsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchUrlsHandler(request); +} diff --git a/lib/chartmetric/__tests__/getChartmetricToken.test.ts b/lib/chartmetric/__tests__/getChartmetricToken.test.ts new file mode 100644 index 00000000..dc942073 --- /dev/null +++ b/lib/chartmetric/__tests__/getChartmetricToken.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getChartmetricToken } from "../getChartmetricToken"; + +describe("getChartmetricToken", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + }); + + it("throws when CHARTMETRIC_REFRESH_TOKEN is not set", async () => { + delete process.env.CHARTMETRIC_REFRESH_TOKEN; + + await expect(getChartmetricToken()).rejects.toThrow("CHARTMETRIC_REFRESH_TOKEN"); + }); + + it("returns access_token on successful exchange", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ access_token: "test-access-token", expires_in: 3600 }), + } as Response); + + const token = await getChartmetricToken(); + + expect(token).toBe("test-access-token"); + expect(fetch).toHaveBeenCalledWith( + "https://api.chartmetric.com/api/token", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ refreshtoken: "test-refresh-token" }), + }), + ); + }); + + it("throws when token exchange returns non-ok response", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 401, + } as Response); + + await expect(getChartmetricToken()).rejects.toThrow("401"); + }); + + it("throws when response has no access_token", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ expires_in: 3600 }), + } as Response); + + await expect(getChartmetricToken()).rejects.toThrow("access_token"); + }); +}); diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts new file mode 100644 index 00000000..c2fc3615 --- /dev/null +++ b/lib/chartmetric/getChartmetricToken.ts @@ -0,0 +1,33 @@ +/** + * Exchanges the Chartmetric refresh token for a short-lived access token. + * + * @returns The Chartmetric access token string. + * @throws Error if the token exchange fails or the env variable is missing. + */ +export async function getChartmetricToken(): Promise { + const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN; + + if (!refreshToken) { + throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set"); + } + + const response = await fetch("https://api.chartmetric.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshtoken: refreshToken }), + }); + + if (!response.ok) { + throw new Error(`Chartmetric token exchange failed with status ${response.status}`); + } + + const data = (await response.json()) as { access_token: string; expires_in: number }; + + if (!data.access_token) { + throw new Error("Chartmetric token response did not include an access_token"); + } + + return data.access_token; +} diff --git a/lib/research/__tests__/proxyToChartmetric.test.ts b/lib/research/__tests__/proxyToChartmetric.test.ts new file mode 100644 index 00000000..4e419ca1 --- /dev/null +++ b/lib/research/__tests__/proxyToChartmetric.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { proxyToChartmetric } from "../proxyToChartmetric"; + +vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ + getChartmetricToken: vi.fn().mockResolvedValue("mock-token"), +})); + +const mockFetch = vi.fn(); + +describe("proxyToChartmetric", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + it("strips the obj wrapper from Chartmetric responses", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ obj: { name: "Drake", id: 3380 } }), + } as Response); + + const result = await proxyToChartmetric("/artist/3380"); + + expect(result.data).toEqual({ name: "Drake", id: 3380 }); + expect(result.status).toBe(200); + }); + + it("passes through responses without obj wrapper", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ results: [{ name: "Drake" }] }), + } as Response); + + const result = await proxyToChartmetric("/search", { q: "Drake" }); + + expect(result.data).toEqual({ results: [{ name: "Drake" }] }); + }); + + it("appends query params to the URL", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ obj: [] }), + } as Response); + + await proxyToChartmetric("/search", { q: "Drake", type: "artists" }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("q=Drake"); + expect(calledUrl).toContain("type=artists"); + }); + + it("sends Authorization header with token", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ obj: {} }), + } as Response); + + await proxyToChartmetric("/artist/3380"); + + const calledOpts = mockFetch.mock.calls[0][1]; + expect(calledOpts.headers).toMatchObject({ Authorization: "Bearer mock-token" }); + }); + + it("returns error data on non-ok response", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + const result = await proxyToChartmetric("/artist/99999"); + + expect(result.status).toBe(404); + expect(result.data).toEqual({ error: "Chartmetric API returned 404" }); + }); + + it("skips empty query param values", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ obj: [] }), + } as Response); + + await proxyToChartmetric("/search", { q: "Drake", type: "" }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain("q=Drake"); + expect(calledUrl).not.toContain("type="); + }); +}); diff --git a/lib/research/__tests__/resolveArtist.test.ts b/lib/research/__tests__/resolveArtist.test.ts new file mode 100644 index 00000000..8a953e27 --- /dev/null +++ b/lib/research/__tests__/resolveArtist.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveArtist } from "../resolveArtist"; + +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +describe("resolveArtist", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns numeric ID directly", async () => { + const result = await resolveArtist("3380"); + + expect(result).toEqual({ id: 3380 }); + expect(proxyToChartmetric).not.toHaveBeenCalled(); + }); + + it("returns error for UUID (not yet implemented)", async () => { + const result = await resolveArtist("de05ba8c-7e29-4f1a-93a7-3635653599f6"); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("not yet implemented"); + }); + + it("searches Chartmetric by name and returns top match", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { artists: [{ id: 3380, name: "Drake" }] }, + status: 200, + }); + + const result = await resolveArtist("Drake"); + + expect(result).toEqual({ id: 3380 }); + expect(proxyToChartmetric).toHaveBeenCalledWith("/search", { + q: "Drake", + type: "artists", + limit: "1", + }); + }); + + it("returns error when no artist found", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { artists: [] }, + status: 200, + }); + + const result = await resolveArtist("xyznonexistent"); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("No artist found"); + }); + + it("returns error when search fails", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { error: "failed" }, + status: 500, + }); + + const result = await resolveArtist("Drake"); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("500"); + }); + + it("returns error for empty string", async () => { + const result = await resolveArtist(""); + + expect(result).toHaveProperty("error"); + expect(result.error).toContain("required"); + }); + + it("trims whitespace from input", async () => { + const result = await resolveArtist(" 3380 "); + + expect(result).toEqual({ id: 3380 }); + }); +}); diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts new file mode 100644 index 00000000..f6eaf1bf --- /dev/null +++ b/lib/research/getResearchAlbumsHandler.ts @@ -0,0 +1,14 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/albums + * + * Returns the album discography for the given artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchAlbumsHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}/albums`); +} diff --git a/lib/research/getResearchAudienceHandler.ts b/lib/research/getResearchAudienceHandler.ts new file mode 100644 index 00000000..9aac2278 --- /dev/null +++ b/lib/research/getResearchAudienceHandler.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/audience + * + * Returns audience demographic stats for the given artist on a specific platform. + * Accepts optional `platform` query param (defaults to "instagram"). + * The platform is embedded in the path, not passed as a query param. + * + * @param request + */ +export async function getResearchAudienceHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform") || "instagram"; + + return handleArtistResearch(request, cmId => `/artist/${cmId}/${platform}-audience-stats`); +} diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts new file mode 100644 index 00000000..2e60d70c --- /dev/null +++ b/lib/research/getResearchCareerHandler.ts @@ -0,0 +1,14 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/career + * + * Returns career history and milestones for the given artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchCareerHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}/career`); +} diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts new file mode 100644 index 00000000..da2f7417 --- /dev/null +++ b/lib/research/getResearchCitiesHandler.ts @@ -0,0 +1,14 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/cities + * + * Returns geographic listening data showing where people listen to the artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchCitiesHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}/where-people-listen`); +} diff --git a/lib/research/getResearchCuratorHandler.ts b/lib/research/getResearchCuratorHandler.ts new file mode 100644 index 00000000..50f224af --- /dev/null +++ b/lib/research/getResearchCuratorHandler.ts @@ -0,0 +1,57 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/curator + * + * Returns details for a specific playlist curator by platform and ID. + * + * @param request - Requires `platform` and `id` query params + */ +export async function getResearchCuratorHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + const id = searchParams.get("id"); + + if (!platform || !id) { + return NextResponse.json( + { status: "error", error: "platform and id parameters are required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const result = await proxyToChartmetric(`/curator/${platform}/${id}`); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Curator lookup failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + ...(typeof result.data === "object" && result.data !== null + ? result.data + : { data: result.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts new file mode 100644 index 00000000..31a5002b --- /dev/null +++ b/lib/research/getResearchDiscoverHandler.ts @@ -0,0 +1,73 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/discover + * + * Discovers artists matching filter criteria (country, genre, listener range, etc.). + * Query params are mapped to Chartmetric's filter API: + * - `country` → `code2` + * - `genre` → `tagId` + * - `sp_monthly_listeners_min` → `sp_ml[]` (min bound) + * - `sp_monthly_listeners_max` → `sp_ml[]` (max bound) + * - `sort` → `sortColumn` + * - `limit` → `limit` + * + * @param request + */ +export async function getResearchDiscoverHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const { searchParams } = new URL(request.url); + const params: Record = {}; + + const country = searchParams.get("country"); + if (country) params.code2 = country; + + const genre = searchParams.get("genre"); + if (genre) params.tagId = genre; + + const sort = searchParams.get("sort"); + if (sort) params.sortColumn = sort; + + const limit = searchParams.get("limit"); + if (limit) params.limit = limit; + + const mlMin = searchParams.get("sp_monthly_listeners_min"); + const mlMax = searchParams.get("sp_monthly_listeners_max"); + if (mlMin) params["sp_ml[]"] = mlMin; + if (mlMax) params["sp_ml[]"] = mlMax; + + const result = await proxyToChartmetric("/artist/list/filter", params); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Discover failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + ...(typeof result.data === "object" && result.data !== null + ? result.data + : { data: result.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts new file mode 100644 index 00000000..d99d1358 --- /dev/null +++ b/lib/research/getResearchFestivalsHandler.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/festivals + * + * Returns the list of music festivals from Chartmetric. + * + * @param request + */ +export async function getResearchFestivalsHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const result = await proxyToChartmetric("/festival/list"); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Failed to fetch festivals" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + ...(typeof result.data === "object" && result.data !== null + ? result.data + : { data: result.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchGenresHandler.ts b/lib/research/getResearchGenresHandler.ts new file mode 100644 index 00000000..6aa728b1 --- /dev/null +++ b/lib/research/getResearchGenresHandler.ts @@ -0,0 +1,47 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/genres + * + * Returns the list of all available genres from Chartmetric. + * Useful for populating genre filter dropdowns in the discover flow. + * + * @param request + */ +export async function getResearchGenresHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const result = await proxyToChartmetric("/genres"); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Failed to fetch genres" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + ...(typeof result.data === "object" && result.data !== null + ? result.data + : { data: result.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts new file mode 100644 index 00000000..131e4ffa --- /dev/null +++ b/lib/research/getResearchInsightsHandler.ts @@ -0,0 +1,15 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/insights + * + * Returns noteworthy insights and highlights for the given artist + * (e.g., trending metrics, chart movements, notable playlist adds). + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchInsightsHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}/noteworthy-insights`); +} diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts new file mode 100644 index 00000000..f9c0989a --- /dev/null +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -0,0 +1,15 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/instagram-posts + * + * Returns recent Instagram posts for the given artist via Chartmetric's + * DeepSocial integration. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchInstagramPostsHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`); +} diff --git a/lib/research/getResearchLookupHandler.ts b/lib/research/getResearchLookupHandler.ts new file mode 100644 index 00000000..60bc4a4f --- /dev/null +++ b/lib/research/getResearchLookupHandler.ts @@ -0,0 +1,70 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([a-zA-Z0-9]+)/; + +/** + * GET /api/research/lookup + * + * Resolves a Spotify artist URL to Chartmetric IDs. Extracts the Spotify artist ID + * from the given URL and calls Chartmetric's get-ids endpoint to retrieve all + * cross-platform identifiers. + * + * @param request - Requires `url` query param containing a Spotify artist URL + */ +export async function getResearchLookupHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const url = searchParams.get("url"); + + if (!url) { + return NextResponse.json( + { status: "error", error: "url parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const match = url.match(SPOTIFY_ARTIST_REGEX); + if (!match) { + return NextResponse.json( + { status: "error", error: "url must be a valid Spotify artist URL" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const spotifyId = match[1]; + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const result = await proxyToChartmetric(`/artist/spotify/${spotifyId}/get-ids`); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Lookup failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + ...(typeof result.data === "object" && result.data !== null + ? result.data + : { data: result.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts new file mode 100644 index 00000000..a39ef2d7 --- /dev/null +++ b/lib/research/getResearchMetricsHandler.ts @@ -0,0 +1,26 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/metrics + * + * Returns platform-specific streaming/social metrics for the given artist. + * Requires `artist` and `source` query params. Source is a platform like + * "spotify", "youtube", "instagram", etc. and is embedded in the path. + * + * @param request + */ +export async function getResearchMetricsHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const source = searchParams.get("source"); + + if (!source) { + return NextResponse.json( + { status: "error", error: "source parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return handleArtistResearch(request, cmId => `/artist/${cmId}/stat/${source}`); +} diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts new file mode 100644 index 00000000..cb948b24 --- /dev/null +++ b/lib/research/getResearchPlaylistHandler.ts @@ -0,0 +1,57 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/playlist + * + * Returns details for a specific playlist by platform and ID. + * + * @param request - Requires `platform` and `id` query params + */ +export async function getResearchPlaylistHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + const id = searchParams.get("id"); + + if (!platform || !id) { + return NextResponse.json( + { status: "error", error: "platform and id parameters are required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const result = await proxyToChartmetric(`/playlist/${platform}/${id}`); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Playlist lookup failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + ...(typeof result.data === "object" && result.data !== null + ? result.data + : { data: result.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts new file mode 100644 index 00000000..5adee18c --- /dev/null +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -0,0 +1,39 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/playlists + * + * Returns playlists featuring the given artist on a specific platform. + * Query params: + * - `platform` — streaming platform (default: "spotify") + * - `status` — "current" or "past" (default: "current") + * - `limit` — max results + * - `sort` — mapped to Chartmetric's `sortColumn` + * - `since` — date filter + * - `editorial` — filter to editorial playlists (boolean string) + * + * @param request + */ +export async function getResearchPlaylistsHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform") || "spotify"; + const status = searchParams.get("status") || "current"; + + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/${platform}/${status}/playlists`, + sp => { + const params: Record = {}; + const limit = sp.get("limit"); + if (limit) params.limit = limit; + const sort = sp.get("sort"); + if (sort) params.sortColumn = sort; + const since = sp.get("since"); + if (since) params.since = since; + const editorial = sp.get("editorial"); + if (editorial) params.editorial = editorial; + return params; + }, + ); +} diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts new file mode 100644 index 00000000..c2923346 --- /dev/null +++ b/lib/research/getResearchProfileHandler.ts @@ -0,0 +1,14 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/profile + * + * Returns the full Chartmetric artist profile for the given artist. + * Requires `artist` query param (name, numeric ID, or UUID). + * + * @param request + */ +export async function getResearchProfileHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}`); +} diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts new file mode 100644 index 00000000..ee71cc65 --- /dev/null +++ b/lib/research/getResearchSearchHandler.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research + * + * Search for artists by name. Returns matching results with profile summaries. + * + * @param request - The incoming request with query params: q, type, limit + */ +export async function getResearchSearchHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q"); + const type = searchParams.get("type") || "artists"; + const limit = searchParams.get("limit") || "10"; + + if (!q) { + return NextResponse.json( + { status: "error", error: "q parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const result = await proxyToChartmetric("/search", { q, type, limit }); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Search failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + const data = result.data as { artists?: unknown[]; tracks?: unknown[]; albums?: unknown[] }; + const results = data?.artists || data?.tracks || data?.albums || []; + + return NextResponse.json( + { status: "success", results }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts new file mode 100644 index 00000000..3379e947 --- /dev/null +++ b/lib/research/getResearchSimilarHandler.ts @@ -0,0 +1,38 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; + +/** + * GET /api/research/similar + * + * Returns similar artists. Uses the configuration-based endpoint when any + * of audience, genre, mood, or musicality params are provided (values: high/medium/low). + * Falls back to the simpler related-artists endpoint when none are present. + * Accepts optional `limit` query param. + * + * @param request + */ +export async function getResearchSimilarHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const hasConfigParams = CONFIG_PARAMS.some(p => searchParams.has(p)); + + return handleArtistResearch( + request, + cmId => + hasConfigParams + ? `/artist/${cmId}/similar-artists/by-configurations` + : `/artist/${cmId}/relatedartists`, + sp => { + const params: Record = {}; + for (const key of CONFIG_PARAMS) { + const val = sp.get(key); + if (val) params[key] = val; + } + const limit = sp.get("limit"); + if (limit) params.limit = limit; + return params; + }, + ); +} diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts new file mode 100644 index 00000000..d3317aa8 --- /dev/null +++ b/lib/research/getResearchTrackHandler.ts @@ -0,0 +1,82 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/track + * + * Searches for a track by name and returns full details. First searches + * Chartmetric for the track, then fetches the full track profile using + * the matched track ID. + * + * @param request - Requires `q` query param with the track search query + */ +export async function getResearchTrackHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q"); + + if (!q) { + return NextResponse.json( + { status: "error", error: "q parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const searchResult = await proxyToChartmetric("/search", { + q, + type: "tracks", + limit: "1", + }); + + if (searchResult.status !== 200) { + return NextResponse.json( + { status: "error", error: "Track search failed" }, + { status: searchResult.status, headers: getCorsHeaders() }, + ); + } + + const searchData = searchResult.data as { tracks?: Array<{ id: number }> }; + const tracks = searchData?.tracks; + + if (!tracks || tracks.length === 0) { + return NextResponse.json( + { status: "error", error: `No track found matching "${q}"` }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const trackId = tracks[0].id; + const detailResult = await proxyToChartmetric(`/track/${trackId}`); + + if (detailResult.status !== 200) { + return NextResponse.json( + { status: "error", error: "Failed to fetch track details" }, + { status: detailResult.status, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + ...(typeof detailResult.data === "object" && detailResult.data !== null + ? detailResult.data + : { data: detailResult.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts new file mode 100644 index 00000000..02239453 --- /dev/null +++ b/lib/research/getResearchTracksHandler.ts @@ -0,0 +1,14 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/tracks + * + * Returns all tracks for the given artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchTracksHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}/tracks`); +} diff --git a/lib/research/getResearchUrlsHandler.ts b/lib/research/getResearchUrlsHandler.ts new file mode 100644 index 00000000..a891ce05 --- /dev/null +++ b/lib/research/getResearchUrlsHandler.ts @@ -0,0 +1,15 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/urls + * + * Returns all known platform URLs (Spotify, Apple Music, YouTube, socials, etc.) + * for the given artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchUrlsHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}/urls`); +} diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts new file mode 100644 index 00000000..6e41dd01 --- /dev/null +++ b/lib/research/handleArtistResearch.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * Shared handler for artist-scoped research endpoints. + * Handles auth, artist resolution, credit deduction, and proxying. + * + * @param request - The incoming request + * @param buildPath - Function that takes the resolved Chartmetric ID and returns the API path + * @param getQueryParams - Optional function to extract additional query params from the request + * @param transformResponse - Optional function to reshape the response data + */ +export async function handleArtistResearch( + request: NextRequest, + buildPath: (cmId: number) => string, + getQueryParams?: (searchParams: URLSearchParams) => Record, + transformResponse?: (data: unknown) => unknown, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const artist = searchParams.get("artist"); + + if (!artist) { + return NextResponse.json( + { status: "error", error: "artist parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const resolved = await resolveArtist(artist); + if (resolved.error) { + return NextResponse.json( + { status: "error", error: resolved.error }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const path = buildPath(resolved.id); + const queryParams = getQueryParams ? getQueryParams(searchParams) : undefined; + const result = await proxyToChartmetric(path, queryParams); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: `Request failed with status ${result.status}` }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + const responseData = transformResponse ? transformResponse(result.data) : result.data; + + return NextResponse.json( + { + status: "success", + ...(typeof responseData === "object" && responseData !== null + ? responseData + : { data: responseData }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/proxyToChartmetric.ts b/lib/research/proxyToChartmetric.ts new file mode 100644 index 00000000..d706ffbe --- /dev/null +++ b/lib/research/proxyToChartmetric.ts @@ -0,0 +1,53 @@ +import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; + +const CHARTMETRIC_BASE = "https://api.chartmetric.com/api"; + +interface ProxyResult { + data: unknown; + status: number; +} + +/** + * Proxies a request to the Chartmetric API with authentication. + * Returns the parsed JSON response with the `obj` wrapper stripped. + * + * @param path - Chartmetric API path (e.g., "/artist/3380/stat/spotify") + * @param queryParams - Optional query parameters to append + * @returns The response data (contents of `obj` if present, otherwise full response) + */ +export async function proxyToChartmetric( + path: string, + queryParams?: Record, +): Promise { + const accessToken = await getChartmetricToken(); + + const url = new URL(`${CHARTMETRIC_BASE}${path}`); + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== "") { + url.searchParams.set(key, value); + } + } + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + return { + data: { error: `Chartmetric API returned ${response.status}` }, + status: response.status, + }; + } + + const json = await response.json(); + + const data = json.obj !== undefined ? json.obj : json; + + return { data, status: response.status }; +} diff --git a/lib/research/resolveArtist.ts b/lib/research/resolveArtist.ts new file mode 100644 index 00000000..86b76bbf --- /dev/null +++ b/lib/research/resolveArtist.ts @@ -0,0 +1,53 @@ +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Resolves an artist identifier (name, UUID, or numeric ID) to a Chartmetric artist ID. + * + * - Numeric string → used directly as Chartmetric ID + * - UUID → future: look up mapping. For now, returns error. + * - String → searches Chartmetric by name, returns top match ID + * + * @param artist - Artist name, Recoup artist ID (UUID), or numeric ID + * @returns The Chartmetric artist ID, or null if not found + */ +export async function resolveArtist( + artist: string, +): Promise<{ id: number; error?: never } | { id?: never; error: string }> { + if (!artist || !artist.trim()) { + return { error: "artist parameter is required" }; + } + + const trimmed = artist.trim(); + + if (/^\d+$/.test(trimmed)) { + return { id: parseInt(trimmed, 10) }; + } + + if (UUID_REGEX.test(trimmed)) { + // TODO: Look up Recoup artist ID → Chartmetric ID mapping in database + return { + error: "Recoup artist ID resolution is not yet implemented. Use an artist name instead.", + }; + } + + const result = await proxyToChartmetric("/search", { + q: trimmed, + type: "artists", + limit: "1", + }); + + if (result.status !== 200) { + return { error: `Search failed with status ${result.status}` }; + } + + const data = result.data as { artists?: Array<{ id: number; name: string }> }; + const artists = data?.artists; + + if (!artists || artists.length === 0) { + return { error: `No artist found matching "${trimmed}"` }; + } + + return { id: artists[0].id }; +} From 9feea8293548c61d2210a0bfff0496d45c413b14 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:25:59 -0400 Subject: [PATCH 03/15] fix: Chartmetric returns 'token' not 'access_token' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered via live API test — the token exchange response uses the field name 'token', not 'access_token'. Now accepts both for resilience. Made-with: Cursor --- lib/chartmetric/__tests__/getChartmetricToken.test.ts | 8 ++++---- lib/chartmetric/getChartmetricToken.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/chartmetric/__tests__/getChartmetricToken.test.ts b/lib/chartmetric/__tests__/getChartmetricToken.test.ts index dc942073..63e981e5 100644 --- a/lib/chartmetric/__tests__/getChartmetricToken.test.ts +++ b/lib/chartmetric/__tests__/getChartmetricToken.test.ts @@ -16,12 +16,12 @@ describe("getChartmetricToken", () => { await expect(getChartmetricToken()).rejects.toThrow("CHARTMETRIC_REFRESH_TOKEN"); }); - it("returns access_token on successful exchange", async () => { + it("returns token on successful exchange", async () => { process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, - json: async () => ({ access_token: "test-access-token", expires_in: 3600 }), + json: async () => ({ token: "test-access-token", expires_in: 3600 }), } as Response); const token = await getChartmetricToken(); @@ -47,7 +47,7 @@ describe("getChartmetricToken", () => { await expect(getChartmetricToken()).rejects.toThrow("401"); }); - it("throws when response has no access_token", async () => { + it("throws when response has no token", async () => { process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; vi.spyOn(globalThis, "fetch").mockResolvedValue({ @@ -55,6 +55,6 @@ describe("getChartmetricToken", () => { json: async () => ({ expires_in: 3600 }), } as Response); - await expect(getChartmetricToken()).rejects.toThrow("access_token"); + await expect(getChartmetricToken()).rejects.toThrow("token"); }); }); diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts index c2fc3615..73e7ea29 100644 --- a/lib/chartmetric/getChartmetricToken.ts +++ b/lib/chartmetric/getChartmetricToken.ts @@ -23,11 +23,13 @@ export async function getChartmetricToken(): Promise { throw new Error(`Chartmetric token exchange failed with status ${response.status}`); } - const data = (await response.json()) as { access_token: string; expires_in: number }; + const data = (await response.json()) as { token?: string; access_token?: string; expires_in: number }; - if (!data.access_token) { - throw new Error("Chartmetric token response did not include an access_token"); + const token = data.token || data.access_token; + + if (!token) { + throw new Error("Chartmetric token response did not include a token"); } - return data.access_token; + return token; } From 52c7ab89196ee17dbf8b460a1df2a40b6ff3de5d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:42:51 -0400 Subject: [PATCH 04/15] fix: playlist lookup resolves non-numeric IDs via search When a Spotify playlist ID (string) is passed instead of a numeric Chartmetric ID, the handler searches for the playlist by ID string and uses the top match. Numeric IDs are passed through directly. Made-with: Cursor --- lib/research/getResearchPlaylistHandler.ts | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts index cb948b24..122475ba 100644 --- a/lib/research/getResearchPlaylistHandler.ts +++ b/lib/research/getResearchPlaylistHandler.ts @@ -36,7 +36,31 @@ export async function getResearchPlaylistHandler(request: NextRequest): Promise< ); } - const result = await proxyToChartmetric(`/playlist/${platform}/${id}`); + let playlistId = id; + + if (!/^\d+$/.test(id)) { + const searchResult = await proxyToChartmetric("/search", { + q: id, + type: "playlists", + limit: "1", + }); + + const playlists = + (searchResult.data as { playlists?: { [key: string]: Array<{ id: number }> } })?.playlists?.[ + platform + ]; + + if (!playlists || playlists.length === 0) { + return NextResponse.json( + { status: "error", error: `No playlist found matching "${id}" on ${platform}` }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + playlistId = String(playlists[0].id); + } + + const result = await proxyToChartmetric(`/playlist/${platform}/${playlistId}`); if (result.status !== 200) { return NextResponse.json( From e6177424790c3b7d2dd543974363e26fe49abde1 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:48:17 -0400 Subject: [PATCH 05/15] fix: normalize response shapes to match OpenAPI schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrays were being spread as { "0": item, "1": item } instead of wrapped under named keys. Fixed all handlers: - albums → { albums: [...] } - tracks → { tracks: [...] } - insights → { insights: [...] } - career → { career: [...] } - similar → { artists: [...], total: N } - playlists → { placements: [...] } - cities → { cities: [{ name, country, listeners }] } (normalized from Chartmetric dict) - urls → { urls: [...] } - genres → { genres: [...] } - festivals → { festivals: [...] } - discover → { artists: [...] } All 20 endpoints verified E2E against live Chartmetric API. Made-with: Cursor --- lib/research/getResearchAlbumsHandler.ts | 7 ++++++- lib/research/getResearchCareerHandler.ts | 7 ++++++- lib/research/getResearchCitiesHandler.ts | 18 +++++++++++++++++- lib/research/getResearchDiscoverHandler.ts | 9 +++------ lib/research/getResearchFestivalsHandler.ts | 9 +++------ lib/research/getResearchGenresHandler.ts | 9 +++------ lib/research/getResearchInsightsHandler.ts | 7 ++++++- lib/research/getResearchPlaylistsHandler.ts | 1 + lib/research/getResearchSimilarHandler.ts | 6 ++++++ lib/research/getResearchTracksHandler.ts | 7 ++++++- lib/research/getResearchUrlsHandler.ts | 14 +++++++++++++- 11 files changed, 70 insertions(+), 24 deletions(-) diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index f6eaf1bf..49b85191 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -10,5 +10,10 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @param request */ export async function getResearchAlbumsHandler(request: NextRequest) { - return handleArtistResearch(request, cmId => `/artist/${cmId}/albums`); + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/albums`, + undefined, + (data) => ({ albums: Array.isArray(data) ? data : [] }), + ); } diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts index 2e60d70c..ecc6cbe5 100644 --- a/lib/research/getResearchCareerHandler.ts +++ b/lib/research/getResearchCareerHandler.ts @@ -10,5 +10,10 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @param request */ export async function getResearchCareerHandler(request: NextRequest) { - return handleArtistResearch(request, cmId => `/artist/${cmId}/career`); + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/career`, + undefined, + (data) => ({ career: Array.isArray(data) ? data : [] }), + ); } diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts index da2f7417..cd6300e3 100644 --- a/lib/research/getResearchCitiesHandler.ts +++ b/lib/research/getResearchCitiesHandler.ts @@ -10,5 +10,21 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @param request */ export async function getResearchCitiesHandler(request: NextRequest) { - return handleArtistResearch(request, cmId => `/artist/${cmId}/where-people-listen`); + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/where-people-listen`, + undefined, + (data) => { + const raw = (data as { cities?: Record> })?.cities || {}; + return { + cities: Object.entries(raw) + .map(([name, points]) => ({ + name, + country: points[points.length - 1]?.code2 || "", + listeners: points[points.length - 1]?.listeners || 0, + })) + .sort((a, b) => b.listeners - a.listeners), + }; + }, + ); } diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts index 31a5002b..c0bf58d4 100644 --- a/lib/research/getResearchDiscoverHandler.ts +++ b/lib/research/getResearchDiscoverHandler.ts @@ -61,13 +61,10 @@ export async function getResearchDiscoverHandler(request: NextRequest): Promise< ); } + const artists = Array.isArray(result.data) ? result.data : []; + return NextResponse.json( - { - status: "success", - ...(typeof result.data === "object" && result.data !== null - ? result.data - : { data: result.data }), - }, + { status: "success", artists }, { status: 200, headers: getCorsHeaders() }, ); } diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts index d99d1358..ff891a91 100644 --- a/lib/research/getResearchFestivalsHandler.ts +++ b/lib/research/getResearchFestivalsHandler.ts @@ -34,13 +34,10 @@ export async function getResearchFestivalsHandler(request: NextRequest): Promise ); } + const festivals = Array.isArray(result.data) ? result.data : []; + return NextResponse.json( - { - status: "success", - ...(typeof result.data === "object" && result.data !== null - ? result.data - : { data: result.data }), - }, + { status: "success", festivals }, { status: 200, headers: getCorsHeaders() }, ); } diff --git a/lib/research/getResearchGenresHandler.ts b/lib/research/getResearchGenresHandler.ts index 6aa728b1..e534b663 100644 --- a/lib/research/getResearchGenresHandler.ts +++ b/lib/research/getResearchGenresHandler.ts @@ -35,13 +35,10 @@ export async function getResearchGenresHandler(request: NextRequest): Promise `/artist/${cmId}/noteworthy-insights`); + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/noteworthy-insights`, + undefined, + (data) => ({ insights: Array.isArray(data) ? data : [] }), + ); } diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts index 5adee18c..39677ccb 100644 --- a/lib/research/getResearchPlaylistsHandler.ts +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -35,5 +35,6 @@ export async function getResearchPlaylistsHandler(request: NextRequest) { if (editorial) params.editorial = editorial; return params; }, + (data) => ({ placements: Array.isArray(data) ? data : [] }), ); } diff --git a/lib/research/getResearchSimilarHandler.ts b/lib/research/getResearchSimilarHandler.ts index 3379e947..4d908659 100644 --- a/lib/research/getResearchSimilarHandler.ts +++ b/lib/research/getResearchSimilarHandler.ts @@ -34,5 +34,11 @@ export async function getResearchSimilarHandler(request: NextRequest) { if (limit) params.limit = limit; return params; }, + (data) => ({ + artists: Array.isArray(data) + ? data + : (data as Record)?.data || [], + total: (data as Record)?.total, + }), ); } diff --git a/lib/research/getResearchTracksHandler.ts b/lib/research/getResearchTracksHandler.ts index 02239453..fbe336e2 100644 --- a/lib/research/getResearchTracksHandler.ts +++ b/lib/research/getResearchTracksHandler.ts @@ -10,5 +10,10 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @param request */ export async function getResearchTracksHandler(request: NextRequest) { - return handleArtistResearch(request, cmId => `/artist/${cmId}/tracks`); + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/tracks`, + undefined, + (data) => ({ tracks: Array.isArray(data) ? data : [] }), + ); } diff --git a/lib/research/getResearchUrlsHandler.ts b/lib/research/getResearchUrlsHandler.ts index a891ce05..d0d10dfa 100644 --- a/lib/research/getResearchUrlsHandler.ts +++ b/lib/research/getResearchUrlsHandler.ts @@ -11,5 +11,17 @@ import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; * @param request */ export async function getResearchUrlsHandler(request: NextRequest) { - return handleArtistResearch(request, cmId => `/artist/${cmId}/urls`); + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/urls`, + undefined, + (data) => ({ + urls: Array.isArray(data) + ? data + : Object.entries(data as Record).map(([domain, url]) => ({ + domain, + url, + })), + }), + ); } From e8a089fa52624916621c6036effac224f76e0e31 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:54:38 -0400 Subject: [PATCH 06/15] fix: send default playlist filters when none specified Chartmetric returns nearly empty results without filter params. Now defaults to editorial + indie + majorCurator + popularIndie when no explicit filters are passed. Users can still override with specific filter params. Made-with: Cursor --- lib/research/getResearchPlaylistsHandler.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts index 39677ccb..15a90800 100644 --- a/lib/research/getResearchPlaylistsHandler.ts +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -31,8 +31,21 @@ export async function getResearchPlaylistsHandler(request: NextRequest) { if (sort) params.sortColumn = sort; const since = sp.get("since"); if (since) params.since = since; - const editorial = sp.get("editorial"); - if (editorial) params.editorial = editorial; + + const hasFilters = sp.get("editorial") || sp.get("indie") || sp.get("majorCurator") || sp.get("personalized") || sp.get("chart"); + if (hasFilters) { + if (sp.get("editorial")) params.editorial = sp.get("editorial")!; + if (sp.get("indie")) params.indie = sp.get("indie")!; + if (sp.get("majorCurator")) params.majorCurator = sp.get("majorCurator")!; + if (sp.get("personalized")) params.personalized = sp.get("personalized")!; + if (sp.get("chart")) params.chart = sp.get("chart")!; + } else { + params.editorial = "true"; + params.indie = "true"; + params.majorCurator = "true"; + params.popularIndie = "true"; + } + return params; }, (data) => ({ placements: Array.isArray(data) ? data : [] }), From 330d77fb95c386dcd5de5e2c2cbd612b7869ea5c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:06:52 -0400 Subject: [PATCH 07/15] feat: add web search, deep research, and people search endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new POST endpoints: - /api/research/web — Perplexity web search (5 credits) - /api/research/deep — Perplexity sonar-deep-research (25 credits) - /api/research/people — Exa people search with LinkedIn profiles (5 credits) All three tested E2E: - Web: returns Wikipedia, press, Apple Music results - Deep: returns 34K char cited report with 50 citations - People: returns LinkedIn profiles of music industry professionals Total research endpoints: 23 (20 Chartmetric + 3 web/deep/people) Made-with: Cursor --- app/api/research/deep/route.ts | 19 ++++++ app/api/research/people/route.ts | 19 ++++++ app/api/research/web/route.ts | 19 ++++++ lib/exa/searchPeople.ts | 65 +++++++++++++++++++ lib/research/postResearchDeepHandler.ts | 71 +++++++++++++++++++++ lib/research/postResearchPeopleHandler.ts | 68 ++++++++++++++++++++ lib/research/postResearchWebHandler.ts | 76 +++++++++++++++++++++++ 7 files changed, 337 insertions(+) create mode 100644 app/api/research/deep/route.ts create mode 100644 app/api/research/people/route.ts create mode 100644 app/api/research/web/route.ts create mode 100644 lib/exa/searchPeople.ts create mode 100644 lib/research/postResearchDeepHandler.ts create mode 100644 lib/research/postResearchPeopleHandler.ts create mode 100644 lib/research/postResearchWebHandler.ts diff --git a/app/api/research/deep/route.ts b/app/api/research/deep/route.ts new file mode 100644 index 00000000..a6237dcc --- /dev/null +++ b/app/api/research/deep/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchDeepHandler } from "@/lib/research/postResearchDeepHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/deep + * + * Deep, comprehensive research with citations. + */ +export async function POST(request: NextRequest) { + return postResearchDeepHandler(request); +} diff --git a/app/api/research/people/route.ts b/app/api/research/people/route.ts new file mode 100644 index 00000000..32d508ca --- /dev/null +++ b/app/api/research/people/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchPeopleHandler } from "@/lib/research/postResearchPeopleHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/people + * + * Search for people in the music industry. + */ +export async function POST(request: NextRequest) { + return postResearchPeopleHandler(request); +} diff --git a/app/api/research/web/route.ts b/app/api/research/web/route.ts new file mode 100644 index 00000000..2c470844 --- /dev/null +++ b/app/api/research/web/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchWebHandler } from "@/lib/research/postResearchWebHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/web + * + * Search the web for real-time information. + */ +export async function POST(request: NextRequest) { + return postResearchWebHandler(request); +} diff --git a/lib/exa/searchPeople.ts b/lib/exa/searchPeople.ts new file mode 100644 index 00000000..9b56680e --- /dev/null +++ b/lib/exa/searchPeople.ts @@ -0,0 +1,65 @@ +const EXA_BASE_URL = "https://api.exa.ai"; + +export interface ExaPersonResult { + title: string; + url: string; + id: string; + publishedDate?: string; + author?: string; + highlights?: string[]; + summary?: string; +} + +export interface ExaPeopleResponse { + results: ExaPersonResult[]; + requestId: string; +} + +/** + * Searches Exa's people index for individuals matching the query. + * Uses Exa's category: "people" filter for multi-source people data + * including LinkedIn profiles. + * + * @param query - Natural language search (e.g., "A&R reps at Atlantic Records") + * @param numResults - Number of results to return (default 10, max 100) + * @returns People search results with highlights + */ +export async function searchPeople( + query: string, + numResults: number = 10, +): Promise { + const apiKey = process.env.EXA_API_KEY; + + if (!apiKey) { + throw new Error("EXA_API_KEY environment variable is not set"); + } + + const response = await fetch(`${EXA_BASE_URL}/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + query, + category: "people", + numResults, + contents: { + highlights: { maxCharacters: 4000 }, + summary: true, + }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Exa API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + const data = await response.json(); + + return { + results: data.results || [], + requestId: data.requestId || "", + }; +} diff --git a/lib/research/postResearchDeepHandler.ts b/lib/research/postResearchDeepHandler.ts new file mode 100644 index 00000000..6f2ad3a0 --- /dev/null +++ b/lib/research/postResearchDeepHandler.ts @@ -0,0 +1,71 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { chatWithPerplexity } from "@/lib/perplexity/chatWithPerplexity"; + +/** + * POST /api/research/deep + * + * Perform deep, comprehensive research on a topic. Browses multiple + * sources extensively and returns a cited report. + * + * @param request - Body: { query } + */ +export async function postResearchDeepHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: { query?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + if (!body.query) { + return NextResponse.json( + { status: "error", error: "query is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 25 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + try { + const result = await chatWithPerplexity( + [{ role: "user", content: body.query }], + "sonar-deep-research", + ); + + return NextResponse.json( + { + status: "success", + content: result.content, + citations: result.citations, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Deep research failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/postResearchPeopleHandler.ts b/lib/research/postResearchPeopleHandler.ts new file mode 100644 index 00000000..60135249 --- /dev/null +++ b/lib/research/postResearchPeopleHandler.ts @@ -0,0 +1,68 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { searchPeople } from "@/lib/exa/searchPeople"; + +/** + * POST /api/research/people + * + * Search for people in the music industry — artists, managers, + * A&R reps, producers, etc. Uses multi-source people data + * including LinkedIn profiles. + * + * @param request - Body: { query, num_results? } + */ +export async function postResearchPeopleHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: { query?: string; num_results?: number }; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + if (!body.query) { + return NextResponse.json( + { status: "error", error: "query is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + try { + const result = await searchPeople(body.query, body.num_results || 10); + + return NextResponse.json( + { + status: "success", + results: result.results, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "People search failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/postResearchWebHandler.ts b/lib/research/postResearchWebHandler.ts new file mode 100644 index 00000000..0247cb2b --- /dev/null +++ b/lib/research/postResearchWebHandler.ts @@ -0,0 +1,76 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; +import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; + +/** + * POST /api/research/web + * + * Search the web for real-time information. Returns ranked results + * with titles, URLs, and content snippets. + * + * @param request - Body: { query, max_results?, country? } + */ +export async function postResearchWebHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: { query?: string; max_results?: number; country?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + if (!body.query) { + return NextResponse.json( + { status: "error", error: "query is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + try { + const searchResponse = await searchPerplexity({ + query: body.query, + max_results: body.max_results || 10, + max_tokens_per_page: 1024, + ...(body.country && { country: body.country }), + }); + + const formatted = formatSearchResultsAsMarkdown(searchResponse); + + return NextResponse.json( + { + status: "success", + results: searchResponse.results, + formatted, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Web search failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} From d9f76afce2ea93691690a6a2856db5dfbdacd82a Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:20:38 -0400 Subject: [PATCH 08/15] feat: add extract and enrich endpoints via Parallel API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/research/extract — scrape specific URLs into clean markdown. Handles JS-heavy pages and PDFs. Accepts objective for focused extraction. (5 credits per URL) - POST /api/research/enrich — structured data enrichment from web research. Pass a JSON schema, get typed data back with citations. Uses Parallel's blocking /result endpoint. Both tested E2E: - Extract: Wikipedia page → title + excerpts - Enrich: "Kaash Paige" → { real_name: "D'Kyla Paige Woolen", hometown: "Dallas, Texas", label: "Rostrum Records", biggest_song: "Love Songs" } Total research endpoints: 25 Made-with: Cursor --- app/api/research/enrich/route.ts | 19 +++++ app/api/research/extract/route.ts | 19 +++++ lib/parallel/enrichEntity.ts | 94 ++++++++++++++++++++++ lib/parallel/extractUrl.ts | 57 +++++++++++++ lib/research/postResearchEnrichHandler.ts | 89 ++++++++++++++++++++ lib/research/postResearchExtractHandler.ts | 75 +++++++++++++++++ 6 files changed, 353 insertions(+) create mode 100644 app/api/research/enrich/route.ts create mode 100644 app/api/research/extract/route.ts create mode 100644 lib/parallel/enrichEntity.ts create mode 100644 lib/parallel/extractUrl.ts create mode 100644 lib/research/postResearchEnrichHandler.ts create mode 100644 lib/research/postResearchExtractHandler.ts diff --git a/app/api/research/enrich/route.ts b/app/api/research/enrich/route.ts new file mode 100644 index 00000000..76b55a91 --- /dev/null +++ b/app/api/research/enrich/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchEnrichHandler } from "@/lib/research/postResearchEnrichHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/enrich + * + * Enrich an entity with structured web research data. + */ +export async function POST(request: NextRequest) { + return postResearchEnrichHandler(request); +} diff --git a/app/api/research/extract/route.ts b/app/api/research/extract/route.ts new file mode 100644 index 00000000..74040d03 --- /dev/null +++ b/app/api/research/extract/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchExtractHandler } from "@/lib/research/postResearchExtractHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/extract + * + * Extract clean markdown from URLs. + */ +export async function POST(request: NextRequest) { + return postResearchExtractHandler(request); +} diff --git a/lib/parallel/enrichEntity.ts b/lib/parallel/enrichEntity.ts new file mode 100644 index 00000000..77b75c93 --- /dev/null +++ b/lib/parallel/enrichEntity.ts @@ -0,0 +1,94 @@ +const PARALLEL_BASE_URL = "https://api.parallel.ai/v1"; + +export interface EnrichResult { + run_id: string; + status: string; + output: unknown; + citations?: Array<{ url: string; title?: string; field?: string }>; +} + +/** + * Enriches an entity with structured data from web research. + * Creates a task run and uses the blocking /result endpoint to wait + * for completion (up to timeout seconds). + * + * @param input - What to research (e.g., "Kaash Paige R&B artist") + * @param outputSchema - JSON schema for the structured output + * @param processor - Processor tier: "base" (fast), "core" (balanced), "ultra" (deep) + * @param timeout - Max seconds to wait for result (default 120) + */ +export async function enrichEntity( + input: string, + outputSchema: Record, + processor: "base" | "core" | "ultra" = "base", + timeout: number = 120, +): Promise { + const apiKey = process.env.PARALLEL_API_KEY; + + if (!apiKey) { + throw new Error("PARALLEL_API_KEY environment variable is not set"); + } + + const createResponse = await fetch(`${PARALLEL_BASE_URL}/tasks/runs`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + input, + processor, + task_spec: { + output_schema: { + type: "json", + json_schema: outputSchema, + }, + }, + }), + }); + + if (!createResponse.ok) { + const errorText = await createResponse.text(); + throw new Error(`Parallel Task API error: ${createResponse.status}\n${errorText}`); + } + + const taskRun = await createResponse.json(); + const runId = taskRun.run_id; + + if (!runId) { + throw new Error("Parallel Task API did not return a run_id"); + } + + const resultResponse = await fetch( + `${PARALLEL_BASE_URL}/tasks/runs/${runId}/result?timeout=${timeout}`, + { headers: { "x-api-key": apiKey } }, + ); + + if (resultResponse.status === 408) { + return { run_id: runId, status: "timeout", output: null }; + } + + if (resultResponse.status === 404) { + throw new Error("Task run failed or not found"); + } + + if (!resultResponse.ok) { + const errorText = await resultResponse.text(); + throw new Error(`Parallel result fetch failed: ${resultResponse.status}\n${errorText}`); + } + + const resultData = await resultResponse.json(); + const output = resultData.output; + + const citations = (output?.basis || []).flatMap( + (b: { field?: string; citations?: Array<{ url: string; title?: string }> }) => + (b.citations || []).map((c) => ({ ...c, field: b.field })), + ); + + return { + run_id: runId, + status: "completed", + output: output?.content, + citations: citations.length > 0 ? citations : undefined, + }; +} diff --git a/lib/parallel/extractUrl.ts b/lib/parallel/extractUrl.ts new file mode 100644 index 00000000..948a858a --- /dev/null +++ b/lib/parallel/extractUrl.ts @@ -0,0 +1,57 @@ +const PARALLEL_BASE_URL = "https://api.parallel.ai/v1beta"; + +export interface ExtractResult { + url: string; + title: string | null; + publish_date: string | null; + excerpts: string[] | null; + full_content: string | null; +} + +export interface ExtractResponse { + extract_id: string; + results: ExtractResult[]; + errors: Array<{ url: string; error: string }>; +} + +/** + * Extracts clean markdown content from one or more public URLs. + * Handles JavaScript-heavy pages and PDFs. Returns focused excerpts + * aligned to an objective, or full page content. + * + * @param urls - URLs to extract (max 10 per request) + * @param objective - What information to focus on (optional, max 3000 chars) + * @param fullContent - Return full page content instead of excerpts + */ +export async function extractUrl( + urls: string[], + objective?: string, + fullContent: boolean = false, +): Promise { + const apiKey = process.env.PARALLEL_API_KEY; + + if (!apiKey) { + throw new Error("PARALLEL_API_KEY environment variable is not set"); + } + + const response = await fetch(`${PARALLEL_BASE_URL}/extract`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + urls, + ...(objective && { objective }), + excerpts: !fullContent, + full_content: fullContent, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Parallel Extract API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + return await response.json(); +} diff --git a/lib/research/postResearchEnrichHandler.ts b/lib/research/postResearchEnrichHandler.ts new file mode 100644 index 00000000..8a5b6c7b --- /dev/null +++ b/lib/research/postResearchEnrichHandler.ts @@ -0,0 +1,89 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { enrichEntity } from "@/lib/parallel/enrichEntity"; + +/** + * POST /api/research/enrich + * + * Enrich an entity with structured data from web research. + * Provide a description of who/what to research and a JSON schema + * defining what fields to extract. Returns typed data with citations. + * + * @param request - Body: { input, schema, processor? } + */ +export async function postResearchEnrichHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: { input?: string; schema?: Record; processor?: "base" | "core" | "ultra" }; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + if (!body.input) { + return NextResponse.json( + { status: "error", error: "input is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + if (!body.schema) { + return NextResponse.json( + { status: "error", error: "schema is required (JSON schema defining output fields)" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const creditCost = body.processor === "ultra" ? 25 : body.processor === "core" ? 10 : 5; + + try { + await deductCredits({ accountId, creditsToDeduct: creditCost }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + try { + const result = await enrichEntity(body.input, body.schema, body.processor || "base"); + + if (result.status === "timeout") { + return NextResponse.json( + { + status: "error", + error: "Enrichment timed out. Try a simpler schema or use processor: 'base'.", + run_id: result.run_id, + }, + { status: 504, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { + status: "success", + output: result.output, + research_basis: result.research_basis, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Enrichment failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/postResearchExtractHandler.ts b/lib/research/postResearchExtractHandler.ts new file mode 100644 index 00000000..5b41e4a6 --- /dev/null +++ b/lib/research/postResearchExtractHandler.ts @@ -0,0 +1,75 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { extractUrl } from "@/lib/parallel/extractUrl"; + +/** + * POST /api/research/extract + * + * Extract clean markdown content from one or more URLs. + * Handles JavaScript-heavy pages and PDFs. + * + * @param request - Body: { urls, objective?, full_content? } + */ +export async function postResearchExtractHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: { urls?: string[]; objective?: string; full_content?: boolean }; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + if (!body.urls || !Array.isArray(body.urls) || body.urls.length === 0) { + return NextResponse.json( + { status: "error", error: "urls array is required (max 10)" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + if (body.urls.length > 10) { + return NextResponse.json( + { status: "error", error: "Maximum 10 URLs per request" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 * body.urls.length }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + try { + const result = await extractUrl(body.urls, body.objective, body.full_content); + + return NextResponse.json( + { + status: "success", + results: result.results, + errors: result.errors.length > 0 ? result.errors : undefined, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Extract failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} From 8e670a2770da1fe1c14ebf0277828b931fa20f3d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:51:06 -0400 Subject: [PATCH 09/15] feat: add 9 research MCP tools for chat agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MCP tools registered automatically — chat app picks them up via the existing MCP connection: - research_artist — search + full profile by name - research_metrics — streaming/social metrics (14 platforms) - research_audience — demographics (Instagram/TikTok/YouTube) - research_cities — top listener cities, normalized - research_similar — competitive landscape with career stages - research_playlists — playlist placements with default filters - research_people — find industry contacts via Exa - research_extract — scrape URLs to markdown via Parallel - research_enrich — structured data enrichment via Parallel All tools use the same underlying functions as the REST endpoints. No changes needed in the chat repo. Made-with: Cursor --- lib/mcp/tools/index.ts | 2 + lib/mcp/tools/research/index.ts | 27 ++++++ .../research/registerResearchArtistTool.ts | 43 +++++++++ .../research/registerResearchAudienceTool.ts | 54 +++++++++++ .../research/registerResearchCitiesTool.ts | 67 ++++++++++++++ .../research/registerResearchEnrichTool.ts | 55 ++++++++++++ .../research/registerResearchExtractTool.ts | 55 ++++++++++++ .../research/registerResearchMetricsTool.ts | 52 +++++++++++ .../research/registerResearchPeopleTool.ts | 47 ++++++++++ .../research/registerResearchPlaylistsTool.ts | 87 ++++++++++++++++++ .../research/registerResearchSimilarTool.ts | 89 +++++++++++++++++++ 11 files changed, 578 insertions(+) create mode 100644 lib/mcp/tools/research/index.ts create mode 100644 lib/mcp/tools/research/registerResearchArtistTool.ts create mode 100644 lib/mcp/tools/research/registerResearchAudienceTool.ts create mode 100644 lib/mcp/tools/research/registerResearchCitiesTool.ts create mode 100644 lib/mcp/tools/research/registerResearchEnrichTool.ts create mode 100644 lib/mcp/tools/research/registerResearchExtractTool.ts create mode 100644 lib/mcp/tools/research/registerResearchMetricsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPeopleTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPlaylistsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchSimilarTool.ts diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index e95da17f..2d78cc3a 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -15,6 +15,7 @@ import { registerAllFileTools } from "./files"; import { registerAllFlamingoTools } from "./flamingo"; import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; +import { registerAllResearchTools } from "./research"; import { registerTranscribeTools } from "./transcribe"; import { registerSendEmailTool } from "./registerSendEmailTool"; import { registerAllArtistTools } from "./artists"; @@ -54,4 +55,5 @@ export const registerAllTools = (server: McpServer): void => { registerUpdateAccountInfoTool(server); registerCreateSegmentsTool(server); registerAllYouTubeTools(server); + registerAllResearchTools(server); }; diff --git a/lib/mcp/tools/research/index.ts b/lib/mcp/tools/research/index.ts new file mode 100644 index 00000000..3e79d679 --- /dev/null +++ b/lib/mcp/tools/research/index.ts @@ -0,0 +1,27 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerResearchArtistTool } from "./registerResearchArtistTool"; +import { registerResearchMetricsTool } from "./registerResearchMetricsTool"; +import { registerResearchAudienceTool } from "./registerResearchAudienceTool"; +import { registerResearchCitiesTool } from "./registerResearchCitiesTool"; +import { registerResearchSimilarTool } from "./registerResearchSimilarTool"; +import { registerResearchPlaylistsTool } from "./registerResearchPlaylistsTool"; +import { registerResearchPeopleTool } from "./registerResearchPeopleTool"; +import { registerResearchExtractTool } from "./registerResearchExtractTool"; +import { registerResearchEnrichTool } from "./registerResearchEnrichTool"; + +/** + * Registers all research-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllResearchTools = (server: McpServer): void => { + registerResearchArtistTool(server); + registerResearchMetricsTool(server); + registerResearchAudienceTool(server); + registerResearchCitiesTool(server); + registerResearchSimilarTool(server); + registerResearchPlaylistsTool(server); + registerResearchPeopleTool(server); + registerResearchExtractTool(server); + registerResearchEnrichTool(server); +}; diff --git a/lib/mcp/tools/research/registerResearchArtistTool.ts b/lib/mcp/tools/research/registerResearchArtistTool.ts new file mode 100644 index 00000000..05091f7d --- /dev/null +++ b/lib/mcp/tools/research/registerResearchArtistTool.ts @@ -0,0 +1,43 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_artist" tool on the MCP server. + * Looks up a music artist by name and returns their full Chartmetric profile. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchArtistTool(server: McpServer): void { + server.registerTool( + "research_artist", + { + description: + "Search for a music artist and get their full profile — bio, genres, social URLs, label, and career stage. Pass an artist name.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}`); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to research artist", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchAudienceTool.ts b/lib/mcp/tools/research/registerResearchAudienceTool.ts new file mode 100644 index 00000000..283fb1a6 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchAudienceTool.ts @@ -0,0 +1,54 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + platform: z + .enum(["instagram", "tiktok", "youtube"]) + .optional() + .default("instagram") + .describe("Platform for audience data (default: instagram)"), +}); + +/** + * Registers the "research_audience" tool on the MCP server. + * Returns audience demographics — age, gender, and country breakdown. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchAudienceTool(server: McpServer): void { + server.registerTool( + "research_audience", + { + description: + "Get audience demographics for an artist — age, gender, and country breakdown. " + + "Defaults to Instagram. Also supports tiktok and youtube.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const platform = args.platform ?? "instagram"; + const result = await proxyToChartmetric( + `/artist/${resolved.id}/${platform}-audience-stats`, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch audience data", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchCitiesTool.ts b/lib/mcp/tools/research/registerResearchCitiesTool.ts new file mode 100644 index 00000000..7d9ec947 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCitiesTool.ts @@ -0,0 +1,67 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_cities" tool on the MCP server. + * Returns the top cities where an artist's fans listen, ranked by listener count. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchCitiesTool(server: McpServer): void { + server.registerTool( + "research_cities", + { + description: + "Get the top cities where an artist's fans listen, ranked by listener concentration. " + + "Shows city name, country, and listener count.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/where-people-listen`, + ); + + const raw = + ( + result.data as { + cities?: Record< + string, + Array<{ code2?: string; listeners?: number }> + >; + } + )?.cities || {}; + + const cities = Object.entries(raw) + .map(([name, points]) => ({ + name, + country: points[points.length - 1]?.code2 || "", + listeners: points[points.length - 1]?.listeners || 0, + })) + .sort((a, b) => b.listeners - a.listeners); + + return getToolResultSuccess({ cities }); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch cities data", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchEnrichTool.ts b/lib/mcp/tools/research/registerResearchEnrichTool.ts new file mode 100644 index 00000000..8a1e342a --- /dev/null +++ b/lib/mcp/tools/research/registerResearchEnrichTool.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { enrichEntity } from "@/lib/parallel/enrichEntity"; + +const schema = z.object({ + input: z.string().describe("What to research"), + schema: z + .record(z.unknown()) + .describe("JSON schema defining the output fields to extract"), + processor: z + .enum(["base", "core", "ultra"]) + .optional() + .default("base") + .describe( + "Processing tier: base (fast), core (balanced), ultra (comprehensive)", + ), +}); + +/** + * Registers the "research_enrich" tool on the MCP server. + * Enriches an entity with structured data from web research using Parallel's task API. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchEnrichTool(server: McpServer): void { + server.registerTool( + "research_enrich", + { + description: + "Get structured data about any entity from web research. " + + "Provide a description and a JSON schema defining what fields to extract. " + + "Returns typed data with citations. " + + "Use processor 'base' for fast results, 'core' for balanced, 'ultra' for comprehensive.", + inputSchema: schema, + }, + async (args) => { + try { + const result = await enrichEntity( + args.input, + args.schema as Record, + args.processor ?? "base", + ); + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to enrich entity", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchExtractTool.ts b/lib/mcp/tools/research/registerResearchExtractTool.ts new file mode 100644 index 00000000..fb50c22c --- /dev/null +++ b/lib/mcp/tools/research/registerResearchExtractTool.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { extractUrl } from "@/lib/parallel/extractUrl"; + +const schema = z.object({ + urls: z + .array(z.string()) + .max(10) + .describe("URLs to extract content from (max 10)"), + objective: z + .string() + .optional() + .describe("What information to focus the extraction on"), + full_content: z + .boolean() + .optional() + .describe("Return full page content instead of focused excerpts"), +}); + +/** + * Registers the "research_extract" tool on the MCP server. + * Extracts clean markdown content from public URLs using Parallel's extract API. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchExtractTool(server: McpServer): void { + server.registerTool( + "research_extract", + { + description: + "Extract clean markdown content from one or more public URLs. " + + "Handles JavaScript-heavy pages and PDFs. " + + "Pass an objective to focus the extraction on specific information.", + inputSchema: schema, + }, + async (args) => { + try { + const result = await extractUrl( + args.urls, + args.objective, + args.full_content ?? false, + ); + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to extract URL content", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchMetricsTool.ts b/lib/mcp/tools/research/registerResearchMetricsTool.ts new file mode 100644 index 00000000..730980c8 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchMetricsTool.ts @@ -0,0 +1,52 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + source: z + .string() + .describe( + "Platform: spotify, instagram, tiktok, youtube_channel, soundcloud, deezer, twitter, facebook, etc.", + ), +}); + +/** + * Registers the "research_metrics" tool on the MCP server. + * Fetches streaming and social metrics for an artist on a specific platform. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchMetricsTool(server: McpServer): void { + server.registerTool( + "research_metrics", + { + description: + "Get streaming and social metrics for an artist on a specific platform. " + + "Supports 14 platforms including spotify, instagram, tiktok, youtube_channel, " + + "soundcloud, deezer, twitter, facebook.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/stat/${args.source}`, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch metrics", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPeopleTool.ts b/lib/mcp/tools/research/registerResearchPeopleTool.ts new file mode 100644 index 00000000..6e9c6f13 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPeopleTool.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { searchPeople } from "@/lib/exa/searchPeople"; + +const schema = z.object({ + query: z.string().describe("Search query for people"), + num_results: z + .number() + .optional() + .default(10) + .describe("Number of results to return (default: 10)"), +}); + +/** + * Registers the "research_people" tool on the MCP server. + * Searches for people in the music industry using Exa's people index. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchPeopleTool(server: McpServer): void { + server.registerTool( + "research_people", + { + description: + "Search for people in the music industry — artists, managers, A&R reps, producers. " + + "Returns profiles with LinkedIn data and summaries.", + inputSchema: schema, + }, + async (args) => { + try { + const result = await searchPeople( + args.query, + args.num_results ?? 10, + ); + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to search for people", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts new file mode 100644 index 00000000..2abde71e --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts @@ -0,0 +1,87 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + platform: z + .string() + .optional() + .default("spotify") + .describe("Streaming platform (default: spotify)"), + status: z + .string() + .optional() + .default("current") + .describe("Playlist status: current or past (default: current)"), + editorial: z + .boolean() + .optional() + .describe("Filter to editorial playlists only"), + limit: z + .number() + .optional() + .default(20) + .describe("Maximum number of playlists to return (default: 20)"), +}); + +/** + * Registers the "research_playlists" tool on the MCP server. + * Returns playlist placements for an artist — editorial, algorithmic, and indie. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchPlaylistsTool(server: McpServer): void { + server.registerTool( + "research_playlists", + { + description: + "Get an artist's playlist placements — editorial, algorithmic, and indie playlists. " + + "Shows playlist name, follower count, track name, and curator.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const platform = args.platform ?? "spotify"; + const status = args.status ?? "current"; + + const queryParams: Record = {}; + if (args.limit) queryParams.limit = String(args.limit); + + if (args.editorial !== undefined) { + queryParams.editorial = String(args.editorial); + } else { + queryParams.editorial = "true"; + queryParams.indie = "true"; + queryParams.majorCurator = "true"; + queryParams.popularIndie = "true"; + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/${platform}/${status}/playlists`, + queryParams, + ); + + const data = result.data; + return getToolResultSuccess({ + placements: Array.isArray(data) ? data : [], + }); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch playlists", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchSimilarTool.ts b/lib/mcp/tools/research/registerResearchSimilarTool.ts new file mode 100644 index 00000000..95c36133 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchSimilarTool.ts @@ -0,0 +1,89 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + audience: z + .string() + .optional() + .describe("Audience overlap weight: high, medium, or low"), + genre: z + .string() + .optional() + .describe("Genre similarity weight: high, medium, or low"), + mood: z + .string() + .optional() + .describe("Mood similarity weight: high, medium, or low"), + musicality: z + .string() + .optional() + .describe("Musicality similarity weight: high, medium, or low"), + limit: z + .number() + .optional() + .default(10) + .describe("Maximum number of similar artists to return (default: 10)"), +}); + +/** + * Registers the "research_similar" tool on the MCP server. + * Finds similar artists using audience overlap, genre, mood, and musicality weights. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchSimilarTool(server: McpServer): void { + server.registerTool( + "research_similar", + { + description: + "Find similar artists based on audience overlap, genre, mood, and musicality. " + + "Returns career stage, momentum, and streaming numbers for each. " + + "Use for competitive analysis and collaboration discovery.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const hasConfigParams = CONFIG_PARAMS.some( + (p) => args[p] !== undefined, + ); + + const queryParams: Record = {}; + for (const key of CONFIG_PARAMS) { + if (args[key]) queryParams[key] = args[key]; + } + if (args.limit) queryParams.limit = String(args.limit); + + const path = hasConfigParams + ? `/artist/${resolved.id}/similar-artists/by-configurations` + : `/artist/${resolved.id}/relatedartists`; + + const result = await proxyToChartmetric(path, queryParams); + + const data = result.data as Record; + return getToolResultSuccess({ + artists: Array.isArray(data) ? data : data?.data || [], + total: Array.isArray(data) ? undefined : data?.total, + }); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to find similar artists", + ); + } + }, + ); +} From 4cd4b3a9450086262a24babc4bc909a3ec3b5fea Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:00:04 -0400 Subject: [PATCH 10/15] feat: add remaining 14 MCP research tools for full parity All 25 REST endpoints now have MCP tool equivalents (23 new + 2 existing). Chat agent has full access to research data. Added: research_profile, research_urls, research_instagram_posts, research_albums, research_tracks, research_career, research_insights, research_lookup, research_track, research_playlist, research_curator, research_discover, research_genres, research_festivals. Made-with: Cursor --- lib/mcp/tools/research/index.ts | 28 +++++++ .../research/registerResearchAlbumsTool.ts | 48 +++++++++++ .../research/registerResearchCareerTool.ts | 47 +++++++++++ .../research/registerResearchCuratorTool.ts | 41 ++++++++++ .../research/registerResearchDiscoverTool.ts | 79 +++++++++++++++++++ .../research/registerResearchFestivalsTool.ts | 35 ++++++++ .../research/registerResearchGenresTool.ts | 34 ++++++++ .../research/registerResearchInsightsTool.ts | 50 ++++++++++++ .../registerResearchInstagramPostsTool.ts | 47 +++++++++++ .../research/registerResearchLookupTool.ts | 47 +++++++++++ .../research/registerResearchPlaylistTool.ts | 62 +++++++++++++++ .../research/registerResearchProfileTool.ts | 46 +++++++++++ .../research/registerResearchTrackTool.ts | 48 +++++++++++ .../research/registerResearchTracksTool.ts | 48 +++++++++++ .../research/registerResearchUrlsTool.ts | 43 ++++++++++ 15 files changed, 703 insertions(+) create mode 100644 lib/mcp/tools/research/registerResearchAlbumsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchCareerTool.ts create mode 100644 lib/mcp/tools/research/registerResearchCuratorTool.ts create mode 100644 lib/mcp/tools/research/registerResearchDiscoverTool.ts create mode 100644 lib/mcp/tools/research/registerResearchFestivalsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchGenresTool.ts create mode 100644 lib/mcp/tools/research/registerResearchInsightsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchInstagramPostsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchLookupTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPlaylistTool.ts create mode 100644 lib/mcp/tools/research/registerResearchProfileTool.ts create mode 100644 lib/mcp/tools/research/registerResearchTrackTool.ts create mode 100644 lib/mcp/tools/research/registerResearchTracksTool.ts create mode 100644 lib/mcp/tools/research/registerResearchUrlsTool.ts diff --git a/lib/mcp/tools/research/index.ts b/lib/mcp/tools/research/index.ts index 3e79d679..1ba446b6 100644 --- a/lib/mcp/tools/research/index.ts +++ b/lib/mcp/tools/research/index.ts @@ -8,6 +8,20 @@ import { registerResearchPlaylistsTool } from "./registerResearchPlaylistsTool"; import { registerResearchPeopleTool } from "./registerResearchPeopleTool"; import { registerResearchExtractTool } from "./registerResearchExtractTool"; import { registerResearchEnrichTool } from "./registerResearchEnrichTool"; +import { registerResearchUrlsTool } from "./registerResearchUrlsTool"; +import { registerResearchInstagramPostsTool } from "./registerResearchInstagramPostsTool"; +import { registerResearchAlbumsTool } from "./registerResearchAlbumsTool"; +import { registerResearchTracksTool } from "./registerResearchTracksTool"; +import { registerResearchCareerTool } from "./registerResearchCareerTool"; +import { registerResearchInsightsTool } from "./registerResearchInsightsTool"; +import { registerResearchLookupTool } from "./registerResearchLookupTool"; +import { registerResearchTrackTool } from "./registerResearchTrackTool"; +import { registerResearchPlaylistTool } from "./registerResearchPlaylistTool"; +import { registerResearchCuratorTool } from "./registerResearchCuratorTool"; +import { registerResearchDiscoverTool } from "./registerResearchDiscoverTool"; +import { registerResearchGenresTool } from "./registerResearchGenresTool"; +import { registerResearchFestivalsTool } from "./registerResearchFestivalsTool"; +import { registerResearchProfileTool } from "./registerResearchProfileTool"; /** * Registers all research-related MCP tools on the server. @@ -24,4 +38,18 @@ export const registerAllResearchTools = (server: McpServer): void => { registerResearchPeopleTool(server); registerResearchExtractTool(server); registerResearchEnrichTool(server); + registerResearchUrlsTool(server); + registerResearchInstagramPostsTool(server); + registerResearchAlbumsTool(server); + registerResearchTracksTool(server); + registerResearchCareerTool(server); + registerResearchInsightsTool(server); + registerResearchLookupTool(server); + registerResearchTrackTool(server); + registerResearchPlaylistTool(server); + registerResearchCuratorTool(server); + registerResearchDiscoverTool(server); + registerResearchGenresTool(server); + registerResearchFestivalsTool(server); + registerResearchProfileTool(server); }; diff --git a/lib/mcp/tools/research/registerResearchAlbumsTool.ts b/lib/mcp/tools/research/registerResearchAlbumsTool.ts new file mode 100644 index 00000000..07075b1f --- /dev/null +++ b/lib/mcp/tools/research/registerResearchAlbumsTool.ts @@ -0,0 +1,48 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_albums" tool on the MCP server. + * Returns an artist's full discography — albums, EPs, and singles with release dates. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchAlbumsTool(server: McpServer): void { + server.registerTool( + "research_albums", + { + description: + "Get an artist's full discography — albums, EPs, and singles with release dates.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/albums`, + ); + const data = result.data; + return getToolResultSuccess({ + albums: Array.isArray(data) ? data : [], + }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch albums", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchCareerTool.ts b/lib/mcp/tools/research/registerResearchCareerTool.ts new file mode 100644 index 00000000..11fbb997 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCareerTool.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_career" tool on the MCP server. + * Returns an artist's career timeline — key milestones, trajectory, and career stage. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchCareerTool(server: McpServer): void { + server.registerTool( + "research_career", + { + description: + "Get an artist's career timeline — key milestones, trajectory, and career stage.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/career`, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch career data", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchCuratorTool.ts b/lib/mcp/tools/research/registerResearchCuratorTool.ts new file mode 100644 index 00000000..8a5f5f3c --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCuratorTool.ts @@ -0,0 +1,41 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + platform: z.string().describe("Streaming platform (e.g. spotify)"), + id: z.string().describe("Curator ID"), +}); + +/** + * Registers the "research_curator" tool on the MCP server. + * Returns a curator profile — who curates a playlist, their other playlists, and follower reach. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchCuratorTool(server: McpServer): void { + server.registerTool( + "research_curator", + { + description: + "Get curator profile — who curates a playlist, their other playlists, and follower reach.", + inputSchema: schema, + }, + async (args) => { + try { + const result = await proxyToChartmetric( + `/curator/${args.platform}/${args.id}`, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch curator", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchDiscoverTool.ts b/lib/mcp/tools/research/registerResearchDiscoverTool.ts new file mode 100644 index 00000000..d6a2848f --- /dev/null +++ b/lib/mcp/tools/research/registerResearchDiscoverTool.ts @@ -0,0 +1,79 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + country: z + .string() + .optional() + .describe("Two-letter country code (e.g. US, GB, DE)"), + genre: z.number().optional().describe("Genre tag ID from research_genres"), + sp_monthly_listeners_min: z + .number() + .optional() + .describe("Minimum Spotify monthly listeners"), + sp_monthly_listeners_max: z + .number() + .optional() + .describe("Maximum Spotify monthly listeners"), + sort: z + .string() + .optional() + .describe("Sort column (e.g. sp_monthly_listeners, sp_followers)"), + limit: z + .number() + .optional() + .default(20) + .describe("Maximum number of artists to return (default: 20)"), +}); + +/** + * Registers the "research_discover" tool on the MCP server. + * Discovers artists by criteria — country, genre, listener count, and growth rate. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchDiscoverTool(server: McpServer): void { + server.registerTool( + "research_discover", + { + description: + "Discover artists by criteria — filter by country, genre, listener count, follower count, and growth rate.", + inputSchema: schema, + }, + async (args) => { + try { + const queryParams: Record = {}; + + if (args.country) queryParams.code2 = args.country; + if (args.genre !== undefined) queryParams.tagId = String(args.genre); + if (args.sp_monthly_listeners_min !== undefined) { + queryParams.sp_monthly_listeners_min = String( + args.sp_monthly_listeners_min, + ); + } + if (args.sp_monthly_listeners_max !== undefined) { + queryParams.sp_monthly_listeners_max = String( + args.sp_monthly_listeners_max, + ); + } + if (args.sort) queryParams.sortColumn = args.sort; + if (args.limit) queryParams.limit = String(args.limit); + + const result = await proxyToChartmetric( + "/artist/list/filter", + queryParams, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to discover artists", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchFestivalsTool.ts b/lib/mcp/tools/research/registerResearchFestivalsTool.ts new file mode 100644 index 00000000..c0fc8362 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchFestivalsTool.ts @@ -0,0 +1,35 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({}); + +/** + * Registers the "research_festivals" tool on the MCP server. + * Lists music festivals. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchFestivalsTool(server: McpServer): void { + server.registerTool( + "research_festivals", + { + description: "List music festivals.", + inputSchema: schema, + }, + async () => { + try { + const result = await proxyToChartmetric("/festival/list"); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch festivals", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchGenresTool.ts b/lib/mcp/tools/research/registerResearchGenresTool.ts new file mode 100644 index 00000000..3025f1d4 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchGenresTool.ts @@ -0,0 +1,34 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({}); + +/** + * Registers the "research_genres" tool on the MCP server. + * Lists all available genre IDs and names for use with the discover tool. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchGenresTool(server: McpServer): void { + server.registerTool( + "research_genres", + { + description: + "List all available genre IDs and names. Use these IDs with the research_discover tool.", + inputSchema: schema, + }, + async () => { + try { + const result = await proxyToChartmetric("/genres"); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch genres", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchInsightsTool.ts b/lib/mcp/tools/research/registerResearchInsightsTool.ts new file mode 100644 index 00000000..a19e58d5 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchInsightsTool.ts @@ -0,0 +1,50 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_insights" tool on the MCP server. + * Returns AI-generated insights about an artist — trends, milestones, and observations. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchInsightsTool(server: McpServer): void { + server.registerTool( + "research_insights", + { + description: + "Get AI-generated insights about an artist — automatically surfaced trends, milestones, and observations.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/noteworthy-insights`, + ); + const data = result.data; + return getToolResultSuccess({ + insights: Array.isArray(data) ? data : [], + }); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch insights", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts new file mode 100644 index 00000000..9e9a922a --- /dev/null +++ b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_instagram_posts" tool on the MCP server. + * Returns an artist's top Instagram posts and reels sorted by engagement. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchInstagramPostsTool(server: McpServer): void { + server.registerTool( + "research_instagram_posts", + { + description: + "Get an artist's top Instagram posts and reels sorted by engagement.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/SNS/deepSocial/cm_artist/${resolved.id}/instagram`, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch Instagram posts", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchLookupTool.ts b/lib/mcp/tools/research/registerResearchLookupTool.ts new file mode 100644 index 00000000..da9b0513 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchLookupTool.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + url: z.string().describe("Spotify URL or platform ID"), +}); + +/** + * Registers the "research_lookup" tool on the MCP server. + * Looks up an artist by a Spotify URL or platform ID and returns the artist profile. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchLookupTool(server: McpServer): void { + server.registerTool( + "research_lookup", + { + description: + "Look up an artist by a Spotify URL or platform ID. Returns the artist profile.", + inputSchema: schema, + }, + async (args) => { + try { + const spotifyId = args.url + .split("/") + .pop() + ?.split("?")[0]; + + if (!spotifyId) { + return getToolResultError("Could not extract Spotify ID from URL"); + } + + const result = await proxyToChartmetric( + `/artist/spotify/${spotifyId}/get-ids`, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to look up artist", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPlaylistTool.ts b/lib/mcp/tools/research/registerResearchPlaylistTool.ts new file mode 100644 index 00000000..abe6033d --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPlaylistTool.ts @@ -0,0 +1,62 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + platform: z + .enum(["spotify", "applemusic", "deezer", "amazon", "youtube"]) + .describe("Streaming platform"), + id: z.string().describe("Playlist ID or name to search for"), +}); + +/** + * Registers the "research_playlist" tool on the MCP server. + * Returns metadata for a single playlist — name, description, follower count, and curator info. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchPlaylistTool(server: McpServer): void { + server.registerTool( + "research_playlist", + { + description: + "Get playlist metadata — name, description, follower count, track count, and curator info.", + inputSchema: schema, + }, + async (args) => { + try { + let numericId = args.id; + + if (!/^\d+$/.test(numericId)) { + const searchResult = await proxyToChartmetric("/search", { + q: numericId, + type: "playlists", + limit: "1", + }); + + const playlists = searchResult.data as Record[]; + if (!Array.isArray(playlists) || playlists.length === 0) { + return getToolResultError( + `No playlist found for "${args.id}"`, + ); + } + + numericId = String((playlists[0] as Record).id); + } + + const result = await proxyToChartmetric( + `/playlist/${args.platform}/${numericId}`, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch playlist", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchProfileTool.ts b/lib/mcp/tools/research/registerResearchProfileTool.ts new file mode 100644 index 00000000..76131b15 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchProfileTool.ts @@ -0,0 +1,46 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_profile" tool on the MCP server. + * Returns a full artist profile — bio, genres, social URLs, label, career stage, and basic metrics. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchProfileTool(server: McpServer): void { + server.registerTool( + "research_profile", + { + description: + "Get a full artist profile — bio, genres, social URLs, label, career stage, and basic metrics. " + + "This is more detailed than research_artist.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}`); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch artist profile", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchTrackTool.ts b/lib/mcp/tools/research/registerResearchTrackTool.ts new file mode 100644 index 00000000..60cc6abe --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTrackTool.ts @@ -0,0 +1,48 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + q: z.string().describe("Track name or Spotify URL"), +}); + +/** + * Registers the "research_track" tool on the MCP server. + * Searches for a track by name or URL and returns its metadata. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchTrackTool(server: McpServer): void { + server.registerTool( + "research_track", + { + description: + "Get track metadata — title, artist, album, release date, and popularity.", + inputSchema: schema, + }, + async (args) => { + try { + const searchResult = await proxyToChartmetric("/search", { + q: args.q, + type: "tracks", + limit: "1", + }); + + const tracks = searchResult.data as Record[]; + if (!Array.isArray(tracks) || tracks.length === 0) { + return getToolResultError(`No track found for "${args.q}"`); + } + + const trackId = (tracks[0] as Record).id; + const result = await proxyToChartmetric(`/track/${trackId}`); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch track", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchTracksTool.ts b/lib/mcp/tools/research/registerResearchTracksTool.ts new file mode 100644 index 00000000..696305ed --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTracksTool.ts @@ -0,0 +1,48 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_tracks" tool on the MCP server. + * Returns all tracks by an artist with popularity data. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchTracksTool(server: McpServer): void { + server.registerTool( + "research_tracks", + { + description: + "Get all tracks by an artist with popularity data.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/tracks`, + ); + const data = result.data; + return getToolResultSuccess({ + tracks: Array.isArray(data) ? data : [], + }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch tracks", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchUrlsTool.ts b/lib/mcp/tools/research/registerResearchUrlsTool.ts new file mode 100644 index 00000000..d6f70de1 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchUrlsTool.ts @@ -0,0 +1,43 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_urls" tool on the MCP server. + * Returns all social and streaming URLs for an artist. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchUrlsTool(server: McpServer): void { + server.registerTool( + "research_urls", + { + description: + "Get all social and streaming URLs for an artist — Spotify, Instagram, TikTok, YouTube, Twitter, SoundCloud, and more.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/urls`); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch URLs", + ); + } + }, + ); +} From a3b8a0458c05b57ae60391e29dd7bcd656522107 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:10:05 -0400 Subject: [PATCH 11/15] fix: remove duplicate research_profile MCP tool, clarify overlapping descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed research_profile — identical to research_artist (both call /artist/{id} on Chartmetric). research_artist is the single entry point for artist profiles. - Updated research_albums description to note get_spotify_artist_albums exists for Spotify-specific data. - Updated research_urls description to note get_artist_socials exists for Recoup-connected socials. 22 research MCP tools total. Made-with: Cursor --- lib/mcp/tools/research/index.ts | 3 -- .../research/registerResearchAlbumsTool.ts | 2 +- .../research/registerResearchProfileTool.ts | 46 ------------------- .../research/registerResearchUrlsTool.ts | 2 +- 4 files changed, 2 insertions(+), 51 deletions(-) delete mode 100644 lib/mcp/tools/research/registerResearchProfileTool.ts diff --git a/lib/mcp/tools/research/index.ts b/lib/mcp/tools/research/index.ts index 1ba446b6..6e845a5f 100644 --- a/lib/mcp/tools/research/index.ts +++ b/lib/mcp/tools/research/index.ts @@ -21,8 +21,6 @@ import { registerResearchCuratorTool } from "./registerResearchCuratorTool"; import { registerResearchDiscoverTool } from "./registerResearchDiscoverTool"; import { registerResearchGenresTool } from "./registerResearchGenresTool"; import { registerResearchFestivalsTool } from "./registerResearchFestivalsTool"; -import { registerResearchProfileTool } from "./registerResearchProfileTool"; - /** * Registers all research-related MCP tools on the server. * @@ -51,5 +49,4 @@ export const registerAllResearchTools = (server: McpServer): void => { registerResearchDiscoverTool(server); registerResearchGenresTool(server); registerResearchFestivalsTool(server); - registerResearchProfileTool(server); }; diff --git a/lib/mcp/tools/research/registerResearchAlbumsTool.ts b/lib/mcp/tools/research/registerResearchAlbumsTool.ts index 07075b1f..10bd2822 100644 --- a/lib/mcp/tools/research/registerResearchAlbumsTool.ts +++ b/lib/mcp/tools/research/registerResearchAlbumsTool.ts @@ -20,7 +20,7 @@ export function registerResearchAlbumsTool(server: McpServer): void { "research_albums", { description: - "Get an artist's full discography — albums, EPs, and singles with release dates.", + "Get an artist's full cross-platform discography — albums, EPs, and singles with release dates. Accepts artist name. For Spotify-specific album data with track listings, use get_spotify_artist_albums instead.", inputSchema: schema, }, async (args) => { diff --git a/lib/mcp/tools/research/registerResearchProfileTool.ts b/lib/mcp/tools/research/registerResearchProfileTool.ts deleted file mode 100644 index 76131b15..00000000 --- a/lib/mcp/tools/research/registerResearchProfileTool.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; - -const schema = z.object({ - artist: z.string().describe("Artist name to research"), -}); - -/** - * Registers the "research_profile" tool on the MCP server. - * Returns a full artist profile — bio, genres, social URLs, label, career stage, and basic metrics. - * - * @param server - The MCP server instance to register the tool on. - */ -export function registerResearchProfileTool(server: McpServer): void { - server.registerTool( - "research_profile", - { - description: - "Get a full artist profile — bio, genres, social URLs, label, career stage, and basic metrics. " + - "This is more detailed than research_artist.", - inputSchema: schema, - }, - async (args) => { - try { - const resolved = await resolveArtist(args.artist); - - if (resolved.error) { - return getToolResultError(resolved.error); - } - - const result = await proxyToChartmetric(`/artist/${resolved.id}`); - return getToolResultSuccess(result.data); - } catch (error) { - return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch artist profile", - ); - } - }, - ); -} diff --git a/lib/mcp/tools/research/registerResearchUrlsTool.ts b/lib/mcp/tools/research/registerResearchUrlsTool.ts index d6f70de1..d2676e2a 100644 --- a/lib/mcp/tools/research/registerResearchUrlsTool.ts +++ b/lib/mcp/tools/research/registerResearchUrlsTool.ts @@ -20,7 +20,7 @@ export function registerResearchUrlsTool(server: McpServer): void { "research_urls", { description: - "Get all social and streaming URLs for an artist — Spotify, Instagram, TikTok, YouTube, Twitter, SoundCloud, and more.", + "Get all known social and streaming URLs for any artist by name — Spotify, Instagram, TikTok, YouTube, Twitter, SoundCloud, and more. For socials connected to a Recoup artist account, use get_artist_socials instead.", inputSchema: schema, }, async (args) => { From 0ddd4c3e64e2490791c878d2fa4bb12b3eff0eef Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:43:33 -0400 Subject: [PATCH 12/15] feat: add milestones, venues, rank, charts, radio endpoints + MCP tools 5 new endpoints (REST + MCP): - milestones: artist activity feed (playlist adds, chart entries) - venues: performance venue history with cities and capacity - rank: global artist ranking (single number) - charts: global chart positions by platform/country - radio: radio station list (3083 stations) All tested E2E. Charts requires interval+type params for Spotify. Total: 30 REST endpoints, 27 MCP tools. Made-with: Cursor --- app/api/research/charts/route.ts | 18 +++++ app/api/research/milestones/route.ts | 18 +++++ app/api/research/radio/route.ts | 18 +++++ app/api/research/rank/route.ts | 18 +++++ app/api/research/venues/route.ts | 18 +++++ lib/mcp/tools/research/index.ts | 10 +++ .../research/registerResearchChartsTool.ts | 65 ++++++++++++++++++ .../registerResearchMilestonesTool.ts | 49 ++++++++++++++ .../research/registerResearchRadioTool.ts | 38 +++++++++++ .../research/registerResearchRankTool.ts | 47 +++++++++++++ .../research/registerResearchVenuesTool.ts | 47 +++++++++++++ lib/research/getResearchChartsHandler.ts | 66 +++++++++++++++++++ lib/research/getResearchMilestonesHandler.ts | 19 ++++++ lib/research/getResearchRadioHandler.ts | 44 +++++++++++++ lib/research/getResearchRankHandler.ts | 18 +++++ lib/research/getResearchVenuesHandler.ts | 18 +++++ 16 files changed, 511 insertions(+) create mode 100644 app/api/research/charts/route.ts create mode 100644 app/api/research/milestones/route.ts create mode 100644 app/api/research/radio/route.ts create mode 100644 app/api/research/rank/route.ts create mode 100644 app/api/research/venues/route.ts create mode 100644 lib/mcp/tools/research/registerResearchChartsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchMilestonesTool.ts create mode 100644 lib/mcp/tools/research/registerResearchRadioTool.ts create mode 100644 lib/mcp/tools/research/registerResearchRankTool.ts create mode 100644 lib/mcp/tools/research/registerResearchVenuesTool.ts create mode 100644 lib/research/getResearchChartsHandler.ts create mode 100644 lib/research/getResearchMilestonesHandler.ts create mode 100644 lib/research/getResearchRadioHandler.ts create mode 100644 lib/research/getResearchRankHandler.ts create mode 100644 lib/research/getResearchVenuesHandler.ts diff --git a/app/api/research/charts/route.ts b/app/api/research/charts/route.ts new file mode 100644 index 00000000..659190fd --- /dev/null +++ b/app/api/research/charts/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchChartsHandler } from "@/lib/research/getResearchChartsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchChartsHandler(request); +} diff --git a/app/api/research/milestones/route.ts b/app/api/research/milestones/route.ts new file mode 100644 index 00000000..4e035604 --- /dev/null +++ b/app/api/research/milestones/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMilestonesHandler } from "@/lib/research/getResearchMilestonesHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchMilestonesHandler(request); +} diff --git a/app/api/research/radio/route.ts b/app/api/research/radio/route.ts new file mode 100644 index 00000000..f3bec7c8 --- /dev/null +++ b/app/api/research/radio/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchRadioHandler } from "@/lib/research/getResearchRadioHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchRadioHandler(request); +} diff --git a/app/api/research/rank/route.ts b/app/api/research/rank/route.ts new file mode 100644 index 00000000..284ddabf --- /dev/null +++ b/app/api/research/rank/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchRankHandler } from "@/lib/research/getResearchRankHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchRankHandler(request); +} diff --git a/app/api/research/venues/route.ts b/app/api/research/venues/route.ts new file mode 100644 index 00000000..a8eb6162 --- /dev/null +++ b/app/api/research/venues/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchVenuesHandler } from "@/lib/research/getResearchVenuesHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchVenuesHandler(request); +} diff --git a/lib/mcp/tools/research/index.ts b/lib/mcp/tools/research/index.ts index 6e845a5f..8d79c759 100644 --- a/lib/mcp/tools/research/index.ts +++ b/lib/mcp/tools/research/index.ts @@ -21,6 +21,11 @@ import { registerResearchCuratorTool } from "./registerResearchCuratorTool"; import { registerResearchDiscoverTool } from "./registerResearchDiscoverTool"; import { registerResearchGenresTool } from "./registerResearchGenresTool"; import { registerResearchFestivalsTool } from "./registerResearchFestivalsTool"; +import { registerResearchMilestonesTool } from "./registerResearchMilestonesTool"; +import { registerResearchVenuesTool } from "./registerResearchVenuesTool"; +import { registerResearchRankTool } from "./registerResearchRankTool"; +import { registerResearchChartsTool } from "./registerResearchChartsTool"; +import { registerResearchRadioTool } from "./registerResearchRadioTool"; /** * Registers all research-related MCP tools on the server. * @@ -49,4 +54,9 @@ export const registerAllResearchTools = (server: McpServer): void => { registerResearchDiscoverTool(server); registerResearchGenresTool(server); registerResearchFestivalsTool(server); + registerResearchMilestonesTool(server); + registerResearchVenuesTool(server); + registerResearchRankTool(server); + registerResearchChartsTool(server); + registerResearchRadioTool(server); }; diff --git a/lib/mcp/tools/research/registerResearchChartsTool.ts b/lib/mcp/tools/research/registerResearchChartsTool.ts new file mode 100644 index 00000000..7e7d26f9 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchChartsTool.ts @@ -0,0 +1,65 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + platform: z + .string() + .describe( + "Chart platform: spotify, applemusic, tiktok, youtube, itunes, shazam, etc.", + ), + country: z + .string() + .optional() + .describe("Two-letter country code (e.g. US, GB, DE)"), + interval: z + .string() + .optional() + .describe("Time interval (e.g. daily, weekly)"), + type: z.string().optional().describe("Chart type (varies by platform)"), + latest: z + .boolean() + .optional() + .default(true) + .describe("Return only the latest chart (default: true)"), +}); + +/** + * Registers the "research_charts" tool on the MCP server. + * Returns global chart positions for a given platform. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchChartsTool(server: McpServer): void { + server.registerTool( + "research_charts", + { + description: + "Get global chart positions for a platform — Spotify, Apple Music, TikTok, YouTube, iTunes, Shazam, etc. " + + "NOT artist-scoped. Returns ranked entries with track/artist info.", + inputSchema: schema, + }, + async (args) => { + try { + const queryParams: Record = {}; + + if (args.country) queryParams.country_code = args.country; + if (args.interval) queryParams.interval = args.interval; + if (args.type) queryParams.type = args.type; + queryParams.latest = String(args.latest ?? true); + + const result = await proxyToChartmetric( + `/charts/${args.platform}`, + queryParams, + ); + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch charts", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchMilestonesTool.ts b/lib/mcp/tools/research/registerResearchMilestonesTool.ts new file mode 100644 index 00000000..19e2c02f --- /dev/null +++ b/lib/mcp/tools/research/registerResearchMilestonesTool.ts @@ -0,0 +1,49 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_milestones" tool on the MCP server. + * Returns an artist's activity feed — playlist adds, chart entries, and notable events. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchMilestonesTool(server: McpServer): void { + server.registerTool( + "research_milestones", + { + description: + "Get an artist's activity feed — playlist adds, chart entries, and notable events. " + + "Each milestone includes a date, summary, platform, track name, and star rating.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/milestones`, + ); + const milestones = (result.data as any)?.insights || []; + return getToolResultSuccess({ milestones }); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch milestones", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchRadioTool.ts b/lib/mcp/tools/research/registerResearchRadioTool.ts new file mode 100644 index 00000000..b1057577 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchRadioTool.ts @@ -0,0 +1,38 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({}); + +/** + * Registers the "research_radio" tool on the MCP server. + * Returns the list of radio stations tracked by Chartmetric. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchRadioTool(server: McpServer): void { + server.registerTool( + "research_radio", + { + description: + "List radio stations tracked by Chartmetric. " + + "Returns station names, formats, and markets.", + inputSchema: schema, + }, + async () => { + try { + const result = await proxyToChartmetric("/radio/station-list"); + const stations = Array.isArray(result.data) ? result.data : []; + return getToolResultSuccess({ stations }); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to fetch radio stations", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchRankTool.ts b/lib/mcp/tools/research/registerResearchRankTool.ts new file mode 100644 index 00000000..b1bd40a0 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchRankTool.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_rank" tool on the MCP server. + * Returns the artist's global Chartmetric ranking. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchRankTool(server: McpServer): void { + server.registerTool( + "research_rank", + { + description: + "Get an artist's global Chartmetric ranking. " + + "Returns a single integer rank value.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/artist-rank`, + ); + const rank = (result.data as any)?.artist_rank || null; + return getToolResultSuccess({ rank }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch rank", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchVenuesTool.ts b/lib/mcp/tools/research/registerResearchVenuesTool.ts new file mode 100644 index 00000000..14e50aef --- /dev/null +++ b/lib/mcp/tools/research/registerResearchVenuesTool.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_venues" tool on the MCP server. + * Returns venues the artist has performed at, including capacity and location. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchVenuesTool(server: McpServer): void { + server.registerTool( + "research_venues", + { + description: + "Get venues an artist has performed at. " + + "Includes venue name, capacity, city, country, and event history.", + inputSchema: schema, + }, + async (args) => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/artist/${resolved.id}/venues`, + ); + const venues = Array.isArray(result.data) ? result.data : []; + return getToolResultSuccess({ venues }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch venues", + ); + } + }, + ); +} diff --git a/lib/research/getResearchChartsHandler.ts b/lib/research/getResearchChartsHandler.ts new file mode 100644 index 00000000..764c7c93 --- /dev/null +++ b/lib/research/getResearchChartsHandler.ts @@ -0,0 +1,66 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/charts + * + * Returns global chart positions for a given platform (Spotify, Apple Music, + * TikTok, YouTube, etc.). NOT artist-scoped — returns the full chart. + * + * @param request + */ +export async function getResearchChartsHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + + if (!platform) { + return NextResponse.json( + { status: "error", error: "platform parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const params: Record = {}; + + const country = searchParams.get("country"); + if (country) params.country_code = country; + + const interval = searchParams.get("interval"); + if (interval) params.interval = interval; + + const type = searchParams.get("type"); + if (type) params.type = type; + + const latest = searchParams.get("latest") ?? "true"; + params.latest = latest; + + const result = await proxyToChartmetric(`/charts/${platform}`, params); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Charts request failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { status: "success", data: result.data }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts new file mode 100644 index 00000000..bb274dfa --- /dev/null +++ b/lib/research/getResearchMilestonesHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/milestones + * + * Returns an artist's activity feed — playlist adds, chart entries, and other + * notable events tracked by Chartmetric. + * + * @param request + */ +export async function getResearchMilestonesHandler(request: NextRequest) { + return handleArtistResearch( + request, + (cmId) => `/artist/${cmId}/milestones`, + undefined, + (data) => ({ milestones: (data as any)?.insights || [] }), + ); +} diff --git a/lib/research/getResearchRadioHandler.ts b/lib/research/getResearchRadioHandler.ts new file mode 100644 index 00000000..98c41eb8 --- /dev/null +++ b/lib/research/getResearchRadioHandler.ts @@ -0,0 +1,44 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/radio + * + * Returns the list of radio stations tracked by Chartmetric. + * NOT artist-scoped — returns global station data. + * + * @param request + */ +export async function getResearchRadioHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const result = await proxyToChartmetric("/radio/station-list"); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: "Failed to fetch radio stations" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + const stations = Array.isArray(result.data) ? result.data : []; + + return NextResponse.json( + { status: "success", stations }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts new file mode 100644 index 00000000..0d668f26 --- /dev/null +++ b/lib/research/getResearchRankHandler.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/rank + * + * Returns the artist's global Chartmetric ranking. + * + * @param request + */ +export async function getResearchRankHandler(request: NextRequest) { + return handleArtistResearch( + request, + (cmId) => `/artist/${cmId}/artist-rank`, + undefined, + (data) => ({ rank: (data as any)?.artist_rank || null }), + ); +} diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts new file mode 100644 index 00000000..692eaa1d --- /dev/null +++ b/lib/research/getResearchVenuesHandler.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/venues + * + * Returns venues the artist has performed at, including capacity and location. + * + * @param request + */ +export async function getResearchVenuesHandler(request: NextRequest) { + return handleArtistResearch( + request, + (cmId) => `/artist/${cmId}/venues`, + undefined, + (data) => ({ venues: Array.isArray(data) ? data : [] }), + ); +} From 6426763b54efaf04b186773a6310c99bb95cb8f7 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:47:53 -0400 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20DRY=20=E2=80=94=20extract=20h?= =?UTF-8?q?andleResearchRequest=20for=20non-artist=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 non-artist handlers (genres, festivals, radio, discover, charts, curator) now use shared handleResearchRequest instead of duplicating auth + deductCredits + proxyToChartmetric boilerplate. Two shared handlers: - handleArtistResearch: 15 artist-scoped handlers - handleResearchRequest: 6 non-artist handlers - 4 handlers with unique logic keep their own (search, track, playlist, lookup — each has custom resolution/parsing) Made-with: Cursor --- lib/research/getResearchChartsHandler.ts | 63 +++++---------- lib/research/getResearchCuratorHandler.ts | 41 ++-------- lib/research/getResearchDiscoverHandler.ts | 85 ++++++--------------- lib/research/getResearchFestivalsHandler.ts | 43 +++-------- lib/research/getResearchGenresHandler.ts | 44 +++-------- lib/research/getResearchRadioHandler.ts | 44 +++-------- lib/research/handleResearchRequest.ts | 60 +++++++++++++++ 7 files changed, 135 insertions(+), 245 deletions(-) create mode 100644 lib/research/handleResearchRequest.ts diff --git a/lib/research/getResearchChartsHandler.ts b/lib/research/getResearchChartsHandler.ts index 764c7c93..7f9a7130 100644 --- a/lib/research/getResearchChartsHandler.ts +++ b/lib/research/getResearchChartsHandler.ts @@ -1,22 +1,16 @@ import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; /** * GET /api/research/charts * - * Returns global chart positions for a given platform (Spotify, Apple Music, - * TikTok, YouTube, etc.). NOT artist-scoped — returns the full chart. + * Returns global chart positions for a platform. Not artist-scoped. + * Requires `platform` query param. Optional: `country`, `interval`, `type`. * * @param request */ -export async function getResearchChartsHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - +export async function getResearchChartsHandler(request: NextRequest) { const { searchParams } = new URL(request.url); const platform = searchParams.get("platform"); @@ -27,40 +21,19 @@ export async function getResearchChartsHandler(request: NextRequest): Promise = {}; - - const country = searchParams.get("country"); - if (country) params.country_code = country; - - const interval = searchParams.get("interval"); - if (interval) params.interval = interval; - - const type = searchParams.get("type"); - if (type) params.type = type; - - const latest = searchParams.get("latest") ?? "true"; - params.latest = latest; - - const result = await proxyToChartmetric(`/charts/${platform}`, params); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: "Charts request failed" }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - - return NextResponse.json( - { status: "success", data: result.data }, - { status: 200, headers: getCorsHeaders() }, + return handleResearchRequest( + request, + () => `/charts/${platform}`, + (sp) => { + const params: Record = {}; + const country = sp.get("country"); + if (country) params.country_code = country; + const interval = sp.get("interval"); + if (interval) params.interval = interval; + const type = sp.get("type"); + if (type) params.type = type; + params.latest = sp.get("latest") ?? "true"; + return params; + }, ); } diff --git a/lib/research/getResearchCuratorHandler.ts b/lib/research/getResearchCuratorHandler.ts index 50f224af..1ec9dcaa 100644 --- a/lib/research/getResearchCuratorHandler.ts +++ b/lib/research/getResearchCuratorHandler.ts @@ -1,21 +1,15 @@ import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; /** * GET /api/research/curator * - * Returns details for a specific playlist curator by platform and ID. + * Returns details for a specific playlist curator. * * @param request - Requires `platform` and `id` query params */ -export async function getResearchCuratorHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - +export async function getResearchCuratorHandler(request: NextRequest) { const { searchParams } = new URL(request.url); const platform = searchParams.get("platform"); const id = searchParams.get("id"); @@ -27,31 +21,8 @@ export async function getResearchCuratorHandler(request: NextRequest): Promise `/curator/${platform}/${id}`, ); } diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts index c0bf58d4..d523e6d0 100644 --- a/lib/research/getResearchDiscoverHandler.ts +++ b/lib/research/getResearchDiscoverHandler.ts @@ -1,70 +1,33 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; /** * GET /api/research/discover * - * Discovers artists matching filter criteria (country, genre, listener range, etc.). - * Query params are mapped to Chartmetric's filter API: - * - `country` → `code2` - * - `genre` → `tagId` - * - `sp_monthly_listeners_min` → `sp_ml[]` (min bound) - * - `sp_monthly_listeners_max` → `sp_ml[]` (max bound) - * - `sort` → `sortColumn` - * - `limit` → `limit` + * Discover artists by criteria — country, genre, listener ranges, growth rate. * * @param request */ -export async function getResearchDiscoverHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } - - const { searchParams } = new URL(request.url); - const params: Record = {}; - - const country = searchParams.get("country"); - if (country) params.code2 = country; - - const genre = searchParams.get("genre"); - if (genre) params.tagId = genre; - - const sort = searchParams.get("sort"); - if (sort) params.sortColumn = sort; - - const limit = searchParams.get("limit"); - if (limit) params.limit = limit; - - const mlMin = searchParams.get("sp_monthly_listeners_min"); - const mlMax = searchParams.get("sp_monthly_listeners_max"); - if (mlMin) params["sp_ml[]"] = mlMin; - if (mlMax) params["sp_ml[]"] = mlMax; - - const result = await proxyToChartmetric("/artist/list/filter", params); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: "Discover failed" }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - - const artists = Array.isArray(result.data) ? result.data : []; - - return NextResponse.json( - { status: "success", artists }, - { status: 200, headers: getCorsHeaders() }, +export async function getResearchDiscoverHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/artist/list/filter", + (sp) => { + const params: Record = {}; + const country = sp.get("country"); + if (country) params.code2 = country; + const genre = sp.get("genre"); + if (genre) params.tagId = genre; + const sort = sp.get("sort"); + if (sort) params.sortColumn = sort; + const limit = sp.get("limit"); + if (limit) params.limit = limit; + const min = sp.get("sp_monthly_listeners_min"); + if (min) params["sp_ml[]"] = min; + const max = sp.get("sp_monthly_listeners_max"); + if (max) params["sp_ml[]"] = max; + return params; + }, + (data) => ({ artists: Array.isArray(data) ? data : [] }), ); } diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts index ff891a91..45231564 100644 --- a/lib/research/getResearchFestivalsHandler.ts +++ b/lib/research/getResearchFestivalsHandler.ts @@ -1,43 +1,18 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; /** * GET /api/research/festivals * - * Returns the list of music festivals from Chartmetric. + * Returns a list of music festivals. * * @param request */ -export async function getResearchFestivalsHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } - - const result = await proxyToChartmetric("/festival/list"); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: "Failed to fetch festivals" }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - - const festivals = Array.isArray(result.data) ? result.data : []; - - return NextResponse.json( - { status: "success", festivals }, - { status: 200, headers: getCorsHeaders() }, +export async function getResearchFestivalsHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/festival/list", + undefined, + (data) => ({ festivals: Array.isArray(data) ? data : [] }), ); } diff --git a/lib/research/getResearchGenresHandler.ts b/lib/research/getResearchGenresHandler.ts index e534b663..7d925c7b 100644 --- a/lib/research/getResearchGenresHandler.ts +++ b/lib/research/getResearchGenresHandler.ts @@ -1,44 +1,18 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; /** * GET /api/research/genres * - * Returns the list of all available genres from Chartmetric. - * Useful for populating genre filter dropdowns in the discover flow. + * Returns all available genre IDs and names. * * @param request */ -export async function getResearchGenresHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } - - const result = await proxyToChartmetric("/genres"); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: "Failed to fetch genres" }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - - const genres = Array.isArray(result.data) ? result.data : []; - - return NextResponse.json( - { status: "success", genres }, - { status: 200, headers: getCorsHeaders() }, +export async function getResearchGenresHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/genres", + undefined, + (data) => ({ genres: Array.isArray(data) ? data : [] }), ); } diff --git a/lib/research/getResearchRadioHandler.ts b/lib/research/getResearchRadioHandler.ts index 98c41eb8..b2159734 100644 --- a/lib/research/getResearchRadioHandler.ts +++ b/lib/research/getResearchRadioHandler.ts @@ -1,44 +1,18 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { deductCredits } from "@/lib/credits/deductCredits"; -import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; /** * GET /api/research/radio * - * Returns the list of radio stations tracked by Chartmetric. - * NOT artist-scoped — returns global station data. + * Returns a list of radio stations. * * @param request */ -export async function getResearchRadioHandler(request: NextRequest): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - const { accountId } = authResult; - - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } - - const result = await proxyToChartmetric("/radio/station-list"); - - if (result.status !== 200) { - return NextResponse.json( - { status: "error", error: "Failed to fetch radio stations" }, - { status: result.status, headers: getCorsHeaders() }, - ); - } - - const stations = Array.isArray(result.data) ? result.data : []; - - return NextResponse.json( - { status: "success", stations }, - { status: 200, headers: getCorsHeaders() }, +export async function getResearchRadioHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/radio/station-list", + undefined, + (data) => ({ stations: Array.isArray(data) ? data : [] }), ); } diff --git a/lib/research/handleResearchRequest.ts b/lib/research/handleResearchRequest.ts new file mode 100644 index 00000000..a60e15a4 --- /dev/null +++ b/lib/research/handleResearchRequest.ts @@ -0,0 +1,60 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * Shared handler for non-artist-scoped research endpoints. + * Handles auth, credit deduction, and proxying to Chartmetric. + * + * @param request - The incoming request + * @param buildPath - Function that returns the Chartmetric API path + * @param getQueryParams - Optional function to extract query params from the request + * @param transformResponse - Optional function to reshape the response data + * @param credits - Number of credits to deduct (default 5) + */ +export async function handleResearchRequest( + request: NextRequest, + buildPath: () => string, + getQueryParams?: (searchParams: URLSearchParams) => Record, + transformResponse?: (data: unknown) => unknown, + credits: number = 5, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + try { + await deductCredits({ accountId, creditsToDeduct: credits }); + } catch { + return NextResponse.json( + { status: "error", error: "Insufficient credits" }, + { status: 402, headers: getCorsHeaders() }, + ); + } + + const { searchParams } = new URL(request.url); + const path = buildPath(); + const queryParams = getQueryParams ? getQueryParams(searchParams) : undefined; + const result = await proxyToChartmetric(path, queryParams); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: `Request failed with status ${result.status}` }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + const responseData = transformResponse ? transformResponse(result.data) : result.data; + + return NextResponse.json( + { + status: "success", + ...(typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) + ? responseData + : { data: responseData }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} From 4ab9b43303637f766fc0c0e643431c867ce7793e Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:02:23 -0400 Subject: [PATCH 14/15] refactor: rename 27 MCP tools from research_* to descriptive names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool names now describe what they do, not what category they're in: - research_artist → get_artist_profile - research_metrics → get_artist_metrics - research_similar → get_similar_artists - research_albums → get_artist_discography - research_people → find_industry_people - research_extract → extract_url_content - research_enrich → enrich_entity - etc. Updated overlap descriptions to differentiate from existing tools: - get_artist_discography vs get_spotify_artist_albums - get_artist_tracks vs get_spotify_artist_top_tracks - get_artist_urls vs get_artist_socials Made-with: Cursor --- lib/mcp/tools/research/registerResearchAlbumsTool.ts | 2 +- lib/mcp/tools/research/registerResearchArtistTool.ts | 2 +- lib/mcp/tools/research/registerResearchAudienceTool.ts | 2 +- lib/mcp/tools/research/registerResearchCareerTool.ts | 2 +- lib/mcp/tools/research/registerResearchChartsTool.ts | 2 +- lib/mcp/tools/research/registerResearchCitiesTool.ts | 2 +- lib/mcp/tools/research/registerResearchCuratorTool.ts | 2 +- lib/mcp/tools/research/registerResearchDiscoverTool.ts | 2 +- lib/mcp/tools/research/registerResearchEnrichTool.ts | 2 +- lib/mcp/tools/research/registerResearchExtractTool.ts | 2 +- lib/mcp/tools/research/registerResearchFestivalsTool.ts | 2 +- lib/mcp/tools/research/registerResearchGenresTool.ts | 2 +- lib/mcp/tools/research/registerResearchInsightsTool.ts | 2 +- lib/mcp/tools/research/registerResearchInstagramPostsTool.ts | 2 +- lib/mcp/tools/research/registerResearchLookupTool.ts | 2 +- lib/mcp/tools/research/registerResearchMetricsTool.ts | 2 +- lib/mcp/tools/research/registerResearchMilestonesTool.ts | 2 +- lib/mcp/tools/research/registerResearchPeopleTool.ts | 2 +- lib/mcp/tools/research/registerResearchPlaylistTool.ts | 2 +- lib/mcp/tools/research/registerResearchPlaylistsTool.ts | 2 +- lib/mcp/tools/research/registerResearchRadioTool.ts | 2 +- lib/mcp/tools/research/registerResearchRankTool.ts | 2 +- lib/mcp/tools/research/registerResearchSimilarTool.ts | 2 +- lib/mcp/tools/research/registerResearchTrackTool.ts | 2 +- lib/mcp/tools/research/registerResearchTracksTool.ts | 4 ++-- lib/mcp/tools/research/registerResearchUrlsTool.ts | 2 +- lib/mcp/tools/research/registerResearchVenuesTool.ts | 2 +- 27 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/mcp/tools/research/registerResearchAlbumsTool.ts b/lib/mcp/tools/research/registerResearchAlbumsTool.ts index 10bd2822..2422f1aa 100644 --- a/lib/mcp/tools/research/registerResearchAlbumsTool.ts +++ b/lib/mcp/tools/research/registerResearchAlbumsTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchAlbumsTool(server: McpServer): void { server.registerTool( - "research_albums", + "get_artist_discography", { description: "Get an artist's full cross-platform discography — albums, EPs, and singles with release dates. Accepts artist name. For Spotify-specific album data with track listings, use get_spotify_artist_albums instead.", diff --git a/lib/mcp/tools/research/registerResearchArtistTool.ts b/lib/mcp/tools/research/registerResearchArtistTool.ts index 05091f7d..fff47f2c 100644 --- a/lib/mcp/tools/research/registerResearchArtistTool.ts +++ b/lib/mcp/tools/research/registerResearchArtistTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchArtistTool(server: McpServer): void { server.registerTool( - "research_artist", + "get_artist_profile", { description: "Search for a music artist and get their full profile — bio, genres, social URLs, label, and career stage. Pass an artist name.", diff --git a/lib/mcp/tools/research/registerResearchAudienceTool.ts b/lib/mcp/tools/research/registerResearchAudienceTool.ts index 283fb1a6..45685afb 100644 --- a/lib/mcp/tools/research/registerResearchAudienceTool.ts +++ b/lib/mcp/tools/research/registerResearchAudienceTool.ts @@ -22,7 +22,7 @@ const schema = z.object({ */ export function registerResearchAudienceTool(server: McpServer): void { server.registerTool( - "research_audience", + "get_artist_audience", { description: "Get audience demographics for an artist — age, gender, and country breakdown. " + diff --git a/lib/mcp/tools/research/registerResearchCareerTool.ts b/lib/mcp/tools/research/registerResearchCareerTool.ts index 11fbb997..1c1af517 100644 --- a/lib/mcp/tools/research/registerResearchCareerTool.ts +++ b/lib/mcp/tools/research/registerResearchCareerTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchCareerTool(server: McpServer): void { server.registerTool( - "research_career", + "get_artist_career", { description: "Get an artist's career timeline — key milestones, trajectory, and career stage.", diff --git a/lib/mcp/tools/research/registerResearchChartsTool.ts b/lib/mcp/tools/research/registerResearchChartsTool.ts index 7e7d26f9..c005eb41 100644 --- a/lib/mcp/tools/research/registerResearchChartsTool.ts +++ b/lib/mcp/tools/research/registerResearchChartsTool.ts @@ -34,7 +34,7 @@ const schema = z.object({ */ export function registerResearchChartsTool(server: McpServer): void { server.registerTool( - "research_charts", + "get_chart_positions", { description: "Get global chart positions for a platform — Spotify, Apple Music, TikTok, YouTube, iTunes, Shazam, etc. " + diff --git a/lib/mcp/tools/research/registerResearchCitiesTool.ts b/lib/mcp/tools/research/registerResearchCitiesTool.ts index 7d9ec947..405ac402 100644 --- a/lib/mcp/tools/research/registerResearchCitiesTool.ts +++ b/lib/mcp/tools/research/registerResearchCitiesTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchCitiesTool(server: McpServer): void { server.registerTool( - "research_cities", + "get_artist_cities", { description: "Get the top cities where an artist's fans listen, ranked by listener concentration. " + diff --git a/lib/mcp/tools/research/registerResearchCuratorTool.ts b/lib/mcp/tools/research/registerResearchCuratorTool.ts index 8a5f5f3c..506d8928 100644 --- a/lib/mcp/tools/research/registerResearchCuratorTool.ts +++ b/lib/mcp/tools/research/registerResearchCuratorTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchCuratorTool(server: McpServer): void { server.registerTool( - "research_curator", + "get_curator_info", { description: "Get curator profile — who curates a playlist, their other playlists, and follower reach.", diff --git a/lib/mcp/tools/research/registerResearchDiscoverTool.ts b/lib/mcp/tools/research/registerResearchDiscoverTool.ts index d6a2848f..5534513c 100644 --- a/lib/mcp/tools/research/registerResearchDiscoverTool.ts +++ b/lib/mcp/tools/research/registerResearchDiscoverTool.ts @@ -37,7 +37,7 @@ const schema = z.object({ */ export function registerResearchDiscoverTool(server: McpServer): void { server.registerTool( - "research_discover", + "discover_artists", { description: "Discover artists by criteria — filter by country, genre, listener count, follower count, and growth rate.", diff --git a/lib/mcp/tools/research/registerResearchEnrichTool.ts b/lib/mcp/tools/research/registerResearchEnrichTool.ts index 8a1e342a..69c0c4ba 100644 --- a/lib/mcp/tools/research/registerResearchEnrichTool.ts +++ b/lib/mcp/tools/research/registerResearchEnrichTool.ts @@ -26,7 +26,7 @@ const schema = z.object({ */ export function registerResearchEnrichTool(server: McpServer): void { server.registerTool( - "research_enrich", + "enrich_entity", { description: "Get structured data about any entity from web research. " + diff --git a/lib/mcp/tools/research/registerResearchExtractTool.ts b/lib/mcp/tools/research/registerResearchExtractTool.ts index fb50c22c..007880d0 100644 --- a/lib/mcp/tools/research/registerResearchExtractTool.ts +++ b/lib/mcp/tools/research/registerResearchExtractTool.ts @@ -27,7 +27,7 @@ const schema = z.object({ */ export function registerResearchExtractTool(server: McpServer): void { server.registerTool( - "research_extract", + "extract_url_content", { description: "Extract clean markdown content from one or more public URLs. " + diff --git a/lib/mcp/tools/research/registerResearchFestivalsTool.ts b/lib/mcp/tools/research/registerResearchFestivalsTool.ts index c0fc8362..b7397515 100644 --- a/lib/mcp/tools/research/registerResearchFestivalsTool.ts +++ b/lib/mcp/tools/research/registerResearchFestivalsTool.ts @@ -14,7 +14,7 @@ const schema = z.object({}); */ export function registerResearchFestivalsTool(server: McpServer): void { server.registerTool( - "research_festivals", + "get_festivals", { description: "List music festivals.", inputSchema: schema, diff --git a/lib/mcp/tools/research/registerResearchGenresTool.ts b/lib/mcp/tools/research/registerResearchGenresTool.ts index 3025f1d4..33ece46f 100644 --- a/lib/mcp/tools/research/registerResearchGenresTool.ts +++ b/lib/mcp/tools/research/registerResearchGenresTool.ts @@ -14,7 +14,7 @@ const schema = z.object({}); */ export function registerResearchGenresTool(server: McpServer): void { server.registerTool( - "research_genres", + "get_genres", { description: "List all available genre IDs and names. Use these IDs with the research_discover tool.", diff --git a/lib/mcp/tools/research/registerResearchInsightsTool.ts b/lib/mcp/tools/research/registerResearchInsightsTool.ts index a19e58d5..83f09b96 100644 --- a/lib/mcp/tools/research/registerResearchInsightsTool.ts +++ b/lib/mcp/tools/research/registerResearchInsightsTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchInsightsTool(server: McpServer): void { server.registerTool( - "research_insights", + "get_artist_insights", { description: "Get AI-generated insights about an artist — automatically surfaced trends, milestones, and observations.", diff --git a/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts index 9e9a922a..bb392382 100644 --- a/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts +++ b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchInstagramPostsTool(server: McpServer): void { server.registerTool( - "research_instagram_posts", + "get_artist_instagram_posts", { description: "Get an artist's top Instagram posts and reels sorted by engagement.", diff --git a/lib/mcp/tools/research/registerResearchLookupTool.ts b/lib/mcp/tools/research/registerResearchLookupTool.ts index da9b0513..1f0bb5bd 100644 --- a/lib/mcp/tools/research/registerResearchLookupTool.ts +++ b/lib/mcp/tools/research/registerResearchLookupTool.ts @@ -16,7 +16,7 @@ const schema = z.object({ */ export function registerResearchLookupTool(server: McpServer): void { server.registerTool( - "research_lookup", + "lookup_artist_by_url", { description: "Look up an artist by a Spotify URL or platform ID. Returns the artist profile.", diff --git a/lib/mcp/tools/research/registerResearchMetricsTool.ts b/lib/mcp/tools/research/registerResearchMetricsTool.ts index 730980c8..db3e826d 100644 --- a/lib/mcp/tools/research/registerResearchMetricsTool.ts +++ b/lib/mcp/tools/research/registerResearchMetricsTool.ts @@ -22,7 +22,7 @@ const schema = z.object({ */ export function registerResearchMetricsTool(server: McpServer): void { server.registerTool( - "research_metrics", + "get_artist_metrics", { description: "Get streaming and social metrics for an artist on a specific platform. " + diff --git a/lib/mcp/tools/research/registerResearchMilestonesTool.ts b/lib/mcp/tools/research/registerResearchMilestonesTool.ts index 19e2c02f..5ea799f6 100644 --- a/lib/mcp/tools/research/registerResearchMilestonesTool.ts +++ b/lib/mcp/tools/research/registerResearchMilestonesTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchMilestonesTool(server: McpServer): void { server.registerTool( - "research_milestones", + "get_artist_milestones", { description: "Get an artist's activity feed — playlist adds, chart entries, and notable events. " + diff --git a/lib/mcp/tools/research/registerResearchPeopleTool.ts b/lib/mcp/tools/research/registerResearchPeopleTool.ts index 6e9c6f13..825f5118 100644 --- a/lib/mcp/tools/research/registerResearchPeopleTool.ts +++ b/lib/mcp/tools/research/registerResearchPeopleTool.ts @@ -21,7 +21,7 @@ const schema = z.object({ */ export function registerResearchPeopleTool(server: McpServer): void { server.registerTool( - "research_people", + "find_industry_people", { description: "Search for people in the music industry — artists, managers, A&R reps, producers. " + diff --git a/lib/mcp/tools/research/registerResearchPlaylistTool.ts b/lib/mcp/tools/research/registerResearchPlaylistTool.ts index abe6033d..decf4e9e 100644 --- a/lib/mcp/tools/research/registerResearchPlaylistTool.ts +++ b/lib/mcp/tools/research/registerResearchPlaylistTool.ts @@ -19,7 +19,7 @@ const schema = z.object({ */ export function registerResearchPlaylistTool(server: McpServer): void { server.registerTool( - "research_playlist", + "get_playlist_info", { description: "Get playlist metadata — name, description, follower count, track count, and curator info.", diff --git a/lib/mcp/tools/research/registerResearchPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts index 2abde71e..8888670b 100644 --- a/lib/mcp/tools/research/registerResearchPlaylistsTool.ts +++ b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts @@ -36,7 +36,7 @@ const schema = z.object({ */ export function registerResearchPlaylistsTool(server: McpServer): void { server.registerTool( - "research_playlists", + "get_artist_playlists", { description: "Get an artist's playlist placements — editorial, algorithmic, and indie playlists. " + diff --git a/lib/mcp/tools/research/registerResearchRadioTool.ts b/lib/mcp/tools/research/registerResearchRadioTool.ts index b1057577..0cd1f012 100644 --- a/lib/mcp/tools/research/registerResearchRadioTool.ts +++ b/lib/mcp/tools/research/registerResearchRadioTool.ts @@ -14,7 +14,7 @@ const schema = z.object({}); */ export function registerResearchRadioTool(server: McpServer): void { server.registerTool( - "research_radio", + "get_radio_stations", { description: "List radio stations tracked by Chartmetric. " + diff --git a/lib/mcp/tools/research/registerResearchRankTool.ts b/lib/mcp/tools/research/registerResearchRankTool.ts index b1bd40a0..225d730e 100644 --- a/lib/mcp/tools/research/registerResearchRankTool.ts +++ b/lib/mcp/tools/research/registerResearchRankTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchRankTool(server: McpServer): void { server.registerTool( - "research_rank", + "get_artist_rank", { description: "Get an artist's global Chartmetric ranking. " + diff --git a/lib/mcp/tools/research/registerResearchSimilarTool.ts b/lib/mcp/tools/research/registerResearchSimilarTool.ts index 95c36133..481ee210 100644 --- a/lib/mcp/tools/research/registerResearchSimilarTool.ts +++ b/lib/mcp/tools/research/registerResearchSimilarTool.ts @@ -40,7 +40,7 @@ const schema = z.object({ */ export function registerResearchSimilarTool(server: McpServer): void { server.registerTool( - "research_similar", + "get_similar_artists", { description: "Find similar artists based on audience overlap, genre, mood, and musicality. " + diff --git a/lib/mcp/tools/research/registerResearchTrackTool.ts b/lib/mcp/tools/research/registerResearchTrackTool.ts index 60cc6abe..4910d67a 100644 --- a/lib/mcp/tools/research/registerResearchTrackTool.ts +++ b/lib/mcp/tools/research/registerResearchTrackTool.ts @@ -16,7 +16,7 @@ const schema = z.object({ */ export function registerResearchTrackTool(server: McpServer): void { server.registerTool( - "research_track", + "get_track_info", { description: "Get track metadata — title, artist, album, release date, and popularity.", diff --git a/lib/mcp/tools/research/registerResearchTracksTool.ts b/lib/mcp/tools/research/registerResearchTracksTool.ts index 696305ed..fcadfcdb 100644 --- a/lib/mcp/tools/research/registerResearchTracksTool.ts +++ b/lib/mcp/tools/research/registerResearchTracksTool.ts @@ -17,10 +17,10 @@ const schema = z.object({ */ export function registerResearchTracksTool(server: McpServer): void { server.registerTool( - "research_tracks", + "get_artist_tracks", { description: - "Get all tracks by an artist with popularity data.", + "Get all tracks by an artist with popularity data. Accepts artist name. For Spotify top 10 tracks with preview URLs, use get_spotify_artist_top_tracks instead.", inputSchema: schema, }, async (args) => { diff --git a/lib/mcp/tools/research/registerResearchUrlsTool.ts b/lib/mcp/tools/research/registerResearchUrlsTool.ts index d2676e2a..0312bb82 100644 --- a/lib/mcp/tools/research/registerResearchUrlsTool.ts +++ b/lib/mcp/tools/research/registerResearchUrlsTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchUrlsTool(server: McpServer): void { server.registerTool( - "research_urls", + "get_artist_urls", { description: "Get all known social and streaming URLs for any artist by name — Spotify, Instagram, TikTok, YouTube, Twitter, SoundCloud, and more. For socials connected to a Recoup artist account, use get_artist_socials instead.", diff --git a/lib/mcp/tools/research/registerResearchVenuesTool.ts b/lib/mcp/tools/research/registerResearchVenuesTool.ts index 14e50aef..10a2755e 100644 --- a/lib/mcp/tools/research/registerResearchVenuesTool.ts +++ b/lib/mcp/tools/research/registerResearchVenuesTool.ts @@ -17,7 +17,7 @@ const schema = z.object({ */ export function registerResearchVenuesTool(server: McpServer): void { server.registerTool( - "research_venues", + "get_artist_venues", { description: "Get venues an artist has performed at. " + From 550becc4a94bbb5c6fb93ccd441cd73787b187e8 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:23:34 -0400 Subject: [PATCH 15/15] fix: address all CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - Credits deducted AFTER provider success, not before - Path traversal prevention — platform params validated against allowlist Major: - MCP tools return error on proxy failure instead of success - Zod validation on all POST body params (web, deep, people, extract, enrich) - popularIndie filter now overrideable in playlists handler All 1,611 tests pass. Made-with: Cursor --- .../research/registerResearchAlbumsTool.ts | 9 ++-- .../research/registerResearchArtistTool.ts | 5 +- .../research/registerResearchAudienceTool.ts | 9 ++-- .../research/registerResearchCareerTool.ts | 13 +++-- .../research/registerResearchChartsTool.ts | 28 +++++----- .../research/registerResearchCitiesTool.ts | 18 +++---- .../research/registerResearchCuratorTool.ts | 21 +++++--- .../research/registerResearchDiscoverTool.ts | 42 +++++---------- .../research/registerResearchFestivalsTool.ts | 7 +-- .../research/registerResearchGenresTool.ts | 3 ++ .../research/registerResearchInsightsTool.ts | 13 +++-- .../registerResearchInstagramPostsTool.ts | 12 ++--- .../research/registerResearchLookupTool.ts | 17 +++--- .../research/registerResearchMetricsTool.ts | 9 ++-- .../registerResearchMilestonesTool.ts | 13 +++-- .../research/registerResearchPlaylistTool.ts | 28 ++++++---- .../research/registerResearchPlaylistsTool.ts | 21 +++++--- .../research/registerResearchRadioTool.ts | 7 +-- .../research/registerResearchRankTool.ts | 16 +++--- .../research/registerResearchSimilarTool.ts | 33 ++++-------- .../research/registerResearchTrackTool.ts | 15 +++--- .../research/registerResearchTracksTool.ts | 9 ++-- .../research/registerResearchUrlsTool.ts | 9 ++-- .../research/registerResearchVenuesTool.ts | 9 ++-- lib/research/getResearchLookupHandler.ts | 15 +++--- lib/research/getResearchPlaylistHandler.ts | 15 ++++-- lib/research/getResearchPlaylistsHandler.ts | 25 +++++++-- lib/research/getResearchSearchHandler.ts | 15 +++--- lib/research/getResearchTrackHandler.ts | 15 +++--- lib/research/handleArtistResearch.ts | 15 +++--- lib/research/handleResearchRequest.ts | 15 +++--- lib/research/postResearchDeepHandler.ts | 41 +++++++-------- lib/research/postResearchEnrichHandler.ts | 52 ++++++++----------- lib/research/postResearchExtractHandler.ts | 50 +++++++----------- lib/research/postResearchPeopleHandler.ts | 42 +++++++-------- lib/research/postResearchWebHandler.ts | 45 ++++++++-------- 36 files changed, 338 insertions(+), 373 deletions(-) diff --git a/lib/mcp/tools/research/registerResearchAlbumsTool.ts b/lib/mcp/tools/research/registerResearchAlbumsTool.ts index 2422f1aa..405b6459 100644 --- a/lib/mcp/tools/research/registerResearchAlbumsTool.ts +++ b/lib/mcp/tools/research/registerResearchAlbumsTool.ts @@ -23,7 +23,7 @@ export function registerResearchAlbumsTool(server: McpServer): void { "Get an artist's full cross-platform discography — albums, EPs, and singles with release dates. Accepts artist name. For Spotify-specific album data with track listings, use get_spotify_artist_albums instead.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -31,9 +31,10 @@ export function registerResearchAlbumsTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/albums`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/albums`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const data = result.data; return getToolResultSuccess({ albums: Array.isArray(data) ? data : [], diff --git a/lib/mcp/tools/research/registerResearchArtistTool.ts b/lib/mcp/tools/research/registerResearchArtistTool.ts index fff47f2c..3a03d34f 100644 --- a/lib/mcp/tools/research/registerResearchArtistTool.ts +++ b/lib/mcp/tools/research/registerResearchArtistTool.ts @@ -23,7 +23,7 @@ export function registerResearchArtistTool(server: McpServer): void { "Search for a music artist and get their full profile — bio, genres, social URLs, label, and career stage. Pass an artist name.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -32,6 +32,9 @@ export function registerResearchArtistTool(server: McpServer): void { } const result = await proxyToChartmetric(`/artist/${resolved.id}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( diff --git a/lib/mcp/tools/research/registerResearchAudienceTool.ts b/lib/mcp/tools/research/registerResearchAudienceTool.ts index 45685afb..ce4dce06 100644 --- a/lib/mcp/tools/research/registerResearchAudienceTool.ts +++ b/lib/mcp/tools/research/registerResearchAudienceTool.ts @@ -29,7 +29,7 @@ export function registerResearchAudienceTool(server: McpServer): void { "Defaults to Instagram. Also supports tiktok and youtube.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -41,12 +41,13 @@ export function registerResearchAudienceTool(server: McpServer): void { const result = await proxyToChartmetric( `/artist/${resolved.id}/${platform}-audience-stats`, ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch audience data", + error instanceof Error ? error.message : "Failed to fetch audience data", ); } }, diff --git a/lib/mcp/tools/research/registerResearchCareerTool.ts b/lib/mcp/tools/research/registerResearchCareerTool.ts index 1c1af517..a472deb3 100644 --- a/lib/mcp/tools/research/registerResearchCareerTool.ts +++ b/lib/mcp/tools/research/registerResearchCareerTool.ts @@ -23,7 +23,7 @@ export function registerResearchCareerTool(server: McpServer): void { "Get an artist's career timeline — key milestones, trajectory, and career stage.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -31,15 +31,14 @@ export function registerResearchCareerTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/career`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/career`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch career data", + error instanceof Error ? error.message : "Failed to fetch career data", ); } }, diff --git a/lib/mcp/tools/research/registerResearchChartsTool.ts b/lib/mcp/tools/research/registerResearchChartsTool.ts index c005eb41..30fae648 100644 --- a/lib/mcp/tools/research/registerResearchChartsTool.ts +++ b/lib/mcp/tools/research/registerResearchChartsTool.ts @@ -7,17 +7,9 @@ import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; const schema = z.object({ platform: z .string() - .describe( - "Chart platform: spotify, applemusic, tiktok, youtube, itunes, shazam, etc.", - ), - country: z - .string() - .optional() - .describe("Two-letter country code (e.g. US, GB, DE)"), - interval: z - .string() - .optional() - .describe("Time interval (e.g. daily, weekly)"), + .describe("Chart platform: spotify, applemusic, tiktok, youtube, itunes, shazam, etc."), + country: z.string().optional().describe("Two-letter country code (e.g. US, GB, DE)"), + interval: z.string().optional().describe("Time interval (e.g. daily, weekly)"), type: z.string().optional().describe("Chart type (varies by platform)"), latest: z .boolean() @@ -41,8 +33,12 @@ export function registerResearchChartsTool(server: McpServer): void { "NOT artist-scoped. Returns ranked entries with track/artist info.", inputSchema: schema, }, - async (args) => { + async args => { try { + if (!/^[a-zA-Z0-9]+$/.test(args.platform)) { + return getToolResultError("Invalid platform: must be alphanumeric with no slashes"); + } + const queryParams: Record = {}; if (args.country) queryParams.country_code = args.country; @@ -50,10 +46,10 @@ export function registerResearchChartsTool(server: McpServer): void { if (args.type) queryParams.type = args.type; queryParams.latest = String(args.latest ?? true); - const result = await proxyToChartmetric( - `/charts/${args.platform}`, - queryParams, - ); + const result = await proxyToChartmetric(`/charts/${args.platform}`, queryParams); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( diff --git a/lib/mcp/tools/research/registerResearchCitiesTool.ts b/lib/mcp/tools/research/registerResearchCitiesTool.ts index 405ac402..8ef57695 100644 --- a/lib/mcp/tools/research/registerResearchCitiesTool.ts +++ b/lib/mcp/tools/research/registerResearchCitiesTool.ts @@ -24,7 +24,7 @@ export function registerResearchCitiesTool(server: McpServer): void { "Shows city name, country, and listener count.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -32,17 +32,15 @@ export function registerResearchCitiesTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/where-people-listen`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/where-people-listen`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const raw = ( result.data as { - cities?: Record< - string, - Array<{ code2?: string; listeners?: number }> - >; + cities?: Record>; } )?.cities || {}; @@ -57,9 +55,7 @@ export function registerResearchCitiesTool(server: McpServer): void { return getToolResultSuccess({ cities }); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch cities data", + error instanceof Error ? error.message : "Failed to fetch cities data", ); } }, diff --git a/lib/mcp/tools/research/registerResearchCuratorTool.ts b/lib/mcp/tools/research/registerResearchCuratorTool.ts index 506d8928..8f7dcb6d 100644 --- a/lib/mcp/tools/research/registerResearchCuratorTool.ts +++ b/lib/mcp/tools/research/registerResearchCuratorTool.ts @@ -4,6 +4,8 @@ import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + const schema = z.object({ platform: z.string().describe("Streaming platform (e.g. spotify)"), id: z.string().describe("Curator ID"), @@ -23,17 +25,22 @@ export function registerResearchCuratorTool(server: McpServer): void { "Get curator profile — who curates a playlist, their other playlists, and follower reach.", inputSchema: schema, }, - async (args) => { + async args => { try { - const result = await proxyToChartmetric( - `/curator/${args.platform}/${args.id}`, - ); + if (!VALID_PLATFORMS.includes(args.platform)) { + return getToolResultError( + `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + ); + } + + const result = await proxyToChartmetric(`/curator/${args.platform}/${args.id}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch curator", + error instanceof Error ? error.message : "Failed to fetch curator", ); } }, diff --git a/lib/mcp/tools/research/registerResearchDiscoverTool.ts b/lib/mcp/tools/research/registerResearchDiscoverTool.ts index 5534513c..a8647f89 100644 --- a/lib/mcp/tools/research/registerResearchDiscoverTool.ts +++ b/lib/mcp/tools/research/registerResearchDiscoverTool.ts @@ -5,23 +5,11 @@ import { getToolResultError } from "@/lib/mcp/getToolResultError"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; const schema = z.object({ - country: z - .string() - .optional() - .describe("Two-letter country code (e.g. US, GB, DE)"), + country: z.string().optional().describe("Two-letter country code (e.g. US, GB, DE)"), genre: z.number().optional().describe("Genre tag ID from research_genres"), - sp_monthly_listeners_min: z - .number() - .optional() - .describe("Minimum Spotify monthly listeners"), - sp_monthly_listeners_max: z - .number() - .optional() - .describe("Maximum Spotify monthly listeners"), - sort: z - .string() - .optional() - .describe("Sort column (e.g. sp_monthly_listeners, sp_followers)"), + sp_monthly_listeners_min: z.number().optional().describe("Minimum Spotify monthly listeners"), + sp_monthly_listeners_max: z.number().optional().describe("Maximum Spotify monthly listeners"), + sort: z.string().optional().describe("Sort column (e.g. sp_monthly_listeners, sp_followers)"), limit: z .number() .optional() @@ -43,35 +31,29 @@ export function registerResearchDiscoverTool(server: McpServer): void { "Discover artists by criteria — filter by country, genre, listener count, follower count, and growth rate.", inputSchema: schema, }, - async (args) => { + async args => { try { const queryParams: Record = {}; if (args.country) queryParams.code2 = args.country; if (args.genre !== undefined) queryParams.tagId = String(args.genre); if (args.sp_monthly_listeners_min !== undefined) { - queryParams.sp_monthly_listeners_min = String( - args.sp_monthly_listeners_min, - ); + queryParams.sp_monthly_listeners_min = String(args.sp_monthly_listeners_min); } if (args.sp_monthly_listeners_max !== undefined) { - queryParams.sp_monthly_listeners_max = String( - args.sp_monthly_listeners_max, - ); + queryParams.sp_monthly_listeners_max = String(args.sp_monthly_listeners_max); } if (args.sort) queryParams.sortColumn = args.sort; if (args.limit) queryParams.limit = String(args.limit); - const result = await proxyToChartmetric( - "/artist/list/filter", - queryParams, - ); + const result = await proxyToChartmetric("/artist/list/filter", queryParams); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to discover artists", + error instanceof Error ? error.message : "Failed to discover artists", ); } }, diff --git a/lib/mcp/tools/research/registerResearchFestivalsTool.ts b/lib/mcp/tools/research/registerResearchFestivalsTool.ts index b7397515..4e598695 100644 --- a/lib/mcp/tools/research/registerResearchFestivalsTool.ts +++ b/lib/mcp/tools/research/registerResearchFestivalsTool.ts @@ -22,12 +22,13 @@ export function registerResearchFestivalsTool(server: McpServer): void { async () => { try { const result = await proxyToChartmetric("/festival/list"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch festivals", + error instanceof Error ? error.message : "Failed to fetch festivals", ); } }, diff --git a/lib/mcp/tools/research/registerResearchGenresTool.ts b/lib/mcp/tools/research/registerResearchGenresTool.ts index 33ece46f..758b7c85 100644 --- a/lib/mcp/tools/research/registerResearchGenresTool.ts +++ b/lib/mcp/tools/research/registerResearchGenresTool.ts @@ -23,6 +23,9 @@ export function registerResearchGenresTool(server: McpServer): void { async () => { try { const result = await proxyToChartmetric("/genres"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( diff --git a/lib/mcp/tools/research/registerResearchInsightsTool.ts b/lib/mcp/tools/research/registerResearchInsightsTool.ts index 83f09b96..96d7f16c 100644 --- a/lib/mcp/tools/research/registerResearchInsightsTool.ts +++ b/lib/mcp/tools/research/registerResearchInsightsTool.ts @@ -23,7 +23,7 @@ export function registerResearchInsightsTool(server: McpServer): void { "Get AI-generated insights about an artist — automatically surfaced trends, milestones, and observations.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -31,18 +31,17 @@ export function registerResearchInsightsTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/noteworthy-insights`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/noteworthy-insights`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const data = result.data; return getToolResultSuccess({ insights: Array.isArray(data) ? data : [], }); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch insights", + error instanceof Error ? error.message : "Failed to fetch insights", ); } }, diff --git a/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts index bb392382..14fae48b 100644 --- a/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts +++ b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts @@ -19,11 +19,10 @@ export function registerResearchInstagramPostsTool(server: McpServer): void { server.registerTool( "get_artist_instagram_posts", { - description: - "Get an artist's top Instagram posts and reels sorted by engagement.", + description: "Get an artist's top Instagram posts and reels sorted by engagement.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -34,12 +33,13 @@ export function registerResearchInstagramPostsTool(server: McpServer): void { const result = await proxyToChartmetric( `/SNS/deepSocial/cm_artist/${resolved.id}/instagram`, ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch Instagram posts", + error instanceof Error ? error.message : "Failed to fetch Instagram posts", ); } }, diff --git a/lib/mcp/tools/research/registerResearchLookupTool.ts b/lib/mcp/tools/research/registerResearchLookupTool.ts index 1f0bb5bd..86380d4a 100644 --- a/lib/mcp/tools/research/registerResearchLookupTool.ts +++ b/lib/mcp/tools/research/registerResearchLookupTool.ts @@ -18,24 +18,21 @@ export function registerResearchLookupTool(server: McpServer): void { server.registerTool( "lookup_artist_by_url", { - description: - "Look up an artist by a Spotify URL or platform ID. Returns the artist profile.", + description: "Look up an artist by a Spotify URL or platform ID. Returns the artist profile.", inputSchema: schema, }, - async (args) => { + async args => { try { - const spotifyId = args.url - .split("/") - .pop() - ?.split("?")[0]; + const spotifyId = args.url.split("/").pop()?.split("?")[0]; if (!spotifyId) { return getToolResultError("Could not extract Spotify ID from URL"); } - const result = await proxyToChartmetric( - `/artist/spotify/${spotifyId}/get-ids`, - ); + const result = await proxyToChartmetric(`/artist/spotify/${spotifyId}/get-ids`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( diff --git a/lib/mcp/tools/research/registerResearchMetricsTool.ts b/lib/mcp/tools/research/registerResearchMetricsTool.ts index db3e826d..9baf7137 100644 --- a/lib/mcp/tools/research/registerResearchMetricsTool.ts +++ b/lib/mcp/tools/research/registerResearchMetricsTool.ts @@ -30,7 +30,7 @@ export function registerResearchMetricsTool(server: McpServer): void { "soundcloud, deezer, twitter, facebook.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -38,9 +38,10 @@ export function registerResearchMetricsTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/stat/${args.source}`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/stat/${args.source}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( diff --git a/lib/mcp/tools/research/registerResearchMilestonesTool.ts b/lib/mcp/tools/research/registerResearchMilestonesTool.ts index 5ea799f6..19e81086 100644 --- a/lib/mcp/tools/research/registerResearchMilestonesTool.ts +++ b/lib/mcp/tools/research/registerResearchMilestonesTool.ts @@ -24,7 +24,7 @@ export function registerResearchMilestonesTool(server: McpServer): void { "Each milestone includes a date, summary, platform, track name, and star rating.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -32,16 +32,15 @@ export function registerResearchMilestonesTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/milestones`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/milestones`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const milestones = (result.data as any)?.insights || []; return getToolResultSuccess({ milestones }); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch milestones", + error instanceof Error ? error.message : "Failed to fetch milestones", ); } }, diff --git a/lib/mcp/tools/research/registerResearchPlaylistTool.ts b/lib/mcp/tools/research/registerResearchPlaylistTool.ts index decf4e9e..764e4fc9 100644 --- a/lib/mcp/tools/research/registerResearchPlaylistTool.ts +++ b/lib/mcp/tools/research/registerResearchPlaylistTool.ts @@ -4,6 +4,8 @@ import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + const schema = z.object({ platform: z .enum(["spotify", "applemusic", "deezer", "amazon", "youtube"]) @@ -25,8 +27,14 @@ export function registerResearchPlaylistTool(server: McpServer): void { "Get playlist metadata — name, description, follower count, track count, and curator info.", inputSchema: schema, }, - async (args) => { + async args => { try { + if (!VALID_PLATFORMS.includes(args.platform)) { + return getToolResultError( + `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + ); + } + let numericId = args.id; if (!/^\d+$/.test(numericId)) { @@ -35,26 +43,26 @@ export function registerResearchPlaylistTool(server: McpServer): void { type: "playlists", limit: "1", }); + if (searchResult.status !== 200) { + return getToolResultError(`Request failed with status ${searchResult.status}`); + } const playlists = searchResult.data as Record[]; if (!Array.isArray(playlists) || playlists.length === 0) { - return getToolResultError( - `No playlist found for "${args.id}"`, - ); + return getToolResultError(`No playlist found for "${args.id}"`); } numericId = String((playlists[0] as Record).id); } - const result = await proxyToChartmetric( - `/playlist/${args.platform}/${numericId}`, - ); + const result = await proxyToChartmetric(`/playlist/${args.platform}/${numericId}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch playlist", + error instanceof Error ? error.message : "Failed to fetch playlist", ); } }, diff --git a/lib/mcp/tools/research/registerResearchPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts index 8888670b..71455604 100644 --- a/lib/mcp/tools/research/registerResearchPlaylistsTool.ts +++ b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts @@ -5,6 +5,8 @@ import { getToolResultError } from "@/lib/mcp/getToolResultError"; import { resolveArtist } from "@/lib/research/resolveArtist"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + const schema = z.object({ artist: z.string().describe("Artist name to research"), platform: z @@ -17,10 +19,7 @@ const schema = z.object({ .optional() .default("current") .describe("Playlist status: current or past (default: current)"), - editorial: z - .boolean() - .optional() - .describe("Filter to editorial playlists only"), + editorial: z.boolean().optional().describe("Filter to editorial playlists only"), limit: z .number() .optional() @@ -43,7 +42,7 @@ export function registerResearchPlaylistsTool(server: McpServer): void { "Shows playlist name, follower count, track name, and curator.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -52,6 +51,11 @@ export function registerResearchPlaylistsTool(server: McpServer): void { } const platform = args.platform ?? "spotify"; + if (!VALID_PLATFORMS.includes(platform)) { + return getToolResultError( + `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + ); + } const status = args.status ?? "current"; const queryParams: Record = {}; @@ -70,6 +74,9 @@ export function registerResearchPlaylistsTool(server: McpServer): void { `/artist/${resolved.id}/${platform}/${status}/playlists`, queryParams, ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const data = result.data; return getToolResultSuccess({ @@ -77,9 +84,7 @@ export function registerResearchPlaylistsTool(server: McpServer): void { }); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch playlists", + error instanceof Error ? error.message : "Failed to fetch playlists", ); } }, diff --git a/lib/mcp/tools/research/registerResearchRadioTool.ts b/lib/mcp/tools/research/registerResearchRadioTool.ts index 0cd1f012..ddd90af2 100644 --- a/lib/mcp/tools/research/registerResearchRadioTool.ts +++ b/lib/mcp/tools/research/registerResearchRadioTool.ts @@ -24,13 +24,14 @@ export function registerResearchRadioTool(server: McpServer): void { async () => { try { const result = await proxyToChartmetric("/radio/station-list"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const stations = Array.isArray(result.data) ? result.data : []; return getToolResultSuccess({ stations }); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to fetch radio stations", + error instanceof Error ? error.message : "Failed to fetch radio stations", ); } }, diff --git a/lib/mcp/tools/research/registerResearchRankTool.ts b/lib/mcp/tools/research/registerResearchRankTool.ts index 225d730e..98031fc6 100644 --- a/lib/mcp/tools/research/registerResearchRankTool.ts +++ b/lib/mcp/tools/research/registerResearchRankTool.ts @@ -20,11 +20,10 @@ export function registerResearchRankTool(server: McpServer): void { "get_artist_rank", { description: - "Get an artist's global Chartmetric ranking. " + - "Returns a single integer rank value.", + "Get an artist's global Chartmetric ranking. " + "Returns a single integer rank value.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -32,15 +31,14 @@ export function registerResearchRankTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/artist-rank`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/artist-rank`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const rank = (result.data as any)?.artist_rank || null; return getToolResultSuccess({ rank }); } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch rank", - ); + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch rank"); } }, ); diff --git a/lib/mcp/tools/research/registerResearchSimilarTool.ts b/lib/mcp/tools/research/registerResearchSimilarTool.ts index 481ee210..538685a9 100644 --- a/lib/mcp/tools/research/registerResearchSimilarTool.ts +++ b/lib/mcp/tools/research/registerResearchSimilarTool.ts @@ -9,22 +9,10 @@ const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; const schema = z.object({ artist: z.string().describe("Artist name to research"), - audience: z - .string() - .optional() - .describe("Audience overlap weight: high, medium, or low"), - genre: z - .string() - .optional() - .describe("Genre similarity weight: high, medium, or low"), - mood: z - .string() - .optional() - .describe("Mood similarity weight: high, medium, or low"), - musicality: z - .string() - .optional() - .describe("Musicality similarity weight: high, medium, or low"), + audience: z.string().optional().describe("Audience overlap weight: high, medium, or low"), + genre: z.string().optional().describe("Genre similarity weight: high, medium, or low"), + mood: z.string().optional().describe("Mood similarity weight: high, medium, or low"), + musicality: z.string().optional().describe("Musicality similarity weight: high, medium, or low"), limit: z .number() .optional() @@ -48,7 +36,7 @@ export function registerResearchSimilarTool(server: McpServer): void { "Use for competitive analysis and collaboration discovery.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -56,9 +44,7 @@ export function registerResearchSimilarTool(server: McpServer): void { return getToolResultError(resolved.error); } - const hasConfigParams = CONFIG_PARAMS.some( - (p) => args[p] !== undefined, - ); + const hasConfigParams = CONFIG_PARAMS.some(p => args[p] !== undefined); const queryParams: Record = {}; for (const key of CONFIG_PARAMS) { @@ -71,6 +57,9 @@ export function registerResearchSimilarTool(server: McpServer): void { : `/artist/${resolved.id}/relatedartists`; const result = await proxyToChartmetric(path, queryParams); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const data = result.data as Record; return getToolResultSuccess({ @@ -79,9 +68,7 @@ export function registerResearchSimilarTool(server: McpServer): void { }); } catch (error) { return getToolResultError( - error instanceof Error - ? error.message - : "Failed to find similar artists", + error instanceof Error ? error.message : "Failed to find similar artists", ); } }, diff --git a/lib/mcp/tools/research/registerResearchTrackTool.ts b/lib/mcp/tools/research/registerResearchTrackTool.ts index 4910d67a..3560d7f5 100644 --- a/lib/mcp/tools/research/registerResearchTrackTool.ts +++ b/lib/mcp/tools/research/registerResearchTrackTool.ts @@ -18,17 +18,19 @@ export function registerResearchTrackTool(server: McpServer): void { server.registerTool( "get_track_info", { - description: - "Get track metadata — title, artist, album, release date, and popularity.", + description: "Get track metadata — title, artist, album, release date, and popularity.", inputSchema: schema, }, - async (args) => { + async args => { try { const searchResult = await proxyToChartmetric("/search", { q: args.q, type: "tracks", limit: "1", }); + if (searchResult.status !== 200) { + return getToolResultError(`Request failed with status ${searchResult.status}`); + } const tracks = searchResult.data as Record[]; if (!Array.isArray(tracks) || tracks.length === 0) { @@ -37,11 +39,12 @@ export function registerResearchTrackTool(server: McpServer): void { const trackId = (tracks[0] as Record).id; const result = await proxyToChartmetric(`/track/${trackId}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch track", - ); + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch track"); } }, ); diff --git a/lib/mcp/tools/research/registerResearchTracksTool.ts b/lib/mcp/tools/research/registerResearchTracksTool.ts index fcadfcdb..d4f48d34 100644 --- a/lib/mcp/tools/research/registerResearchTracksTool.ts +++ b/lib/mcp/tools/research/registerResearchTracksTool.ts @@ -23,7 +23,7 @@ export function registerResearchTracksTool(server: McpServer): void { "Get all tracks by an artist with popularity data. Accepts artist name. For Spotify top 10 tracks with preview URLs, use get_spotify_artist_top_tracks instead.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -31,9 +31,10 @@ export function registerResearchTracksTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/tracks`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/tracks`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const data = result.data; return getToolResultSuccess({ tracks: Array.isArray(data) ? data : [], diff --git a/lib/mcp/tools/research/registerResearchUrlsTool.ts b/lib/mcp/tools/research/registerResearchUrlsTool.ts index 0312bb82..5812cac4 100644 --- a/lib/mcp/tools/research/registerResearchUrlsTool.ts +++ b/lib/mcp/tools/research/registerResearchUrlsTool.ts @@ -23,7 +23,7 @@ export function registerResearchUrlsTool(server: McpServer): void { "Get all known social and streaming URLs for any artist by name — Spotify, Instagram, TikTok, YouTube, Twitter, SoundCloud, and more. For socials connected to a Recoup artist account, use get_artist_socials instead.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -32,11 +32,12 @@ export function registerResearchUrlsTool(server: McpServer): void { } const result = await proxyToChartmetric(`/artist/${resolved.id}/urls`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } return getToolResultSuccess(result.data); } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to fetch URLs", - ); + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch URLs"); } }, ); diff --git a/lib/mcp/tools/research/registerResearchVenuesTool.ts b/lib/mcp/tools/research/registerResearchVenuesTool.ts index 10a2755e..39bfe17a 100644 --- a/lib/mcp/tools/research/registerResearchVenuesTool.ts +++ b/lib/mcp/tools/research/registerResearchVenuesTool.ts @@ -24,7 +24,7 @@ export function registerResearchVenuesTool(server: McpServer): void { "Includes venue name, capacity, city, country, and event history.", inputSchema: schema, }, - async (args) => { + async args => { try { const resolved = await resolveArtist(args.artist); @@ -32,9 +32,10 @@ export function registerResearchVenuesTool(server: McpServer): void { return getToolResultError(resolved.error); } - const result = await proxyToChartmetric( - `/artist/${resolved.id}/venues`, - ); + const result = await proxyToChartmetric(`/artist/${resolved.id}/venues`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } const venues = Array.isArray(result.data) ? result.data : []; return getToolResultSuccess({ venues }); } catch (error) { diff --git a/lib/research/getResearchLookupHandler.ts b/lib/research/getResearchLookupHandler.ts index 60bc4a4f..bb1d5889 100644 --- a/lib/research/getResearchLookupHandler.ts +++ b/lib/research/getResearchLookupHandler.ts @@ -40,15 +40,6 @@ export async function getResearchLookupHandler(request: NextRequest): Promise `/artist/${cmId}/${platform}/${status}/playlists`, @@ -32,11 +42,18 @@ export async function getResearchPlaylistsHandler(request: NextRequest) { const since = sp.get("since"); if (since) params.since = since; - const hasFilters = sp.get("editorial") || sp.get("indie") || sp.get("majorCurator") || sp.get("personalized") || sp.get("chart"); + const hasFilters = + sp.get("editorial") || + sp.get("indie") || + sp.get("majorCurator") || + sp.get("popularIndie") || + sp.get("personalized") || + sp.get("chart"); if (hasFilters) { if (sp.get("editorial")) params.editorial = sp.get("editorial")!; if (sp.get("indie")) params.indie = sp.get("indie")!; if (sp.get("majorCurator")) params.majorCurator = sp.get("majorCurator")!; + if (sp.get("popularIndie")) params.popularIndie = sp.get("popularIndie")!; if (sp.get("personalized")) params.personalized = sp.get("personalized")!; if (sp.get("chart")) params.chart = sp.get("chart")!; } else { @@ -48,6 +65,6 @@ export async function getResearchPlaylistsHandler(request: NextRequest) { return params; }, - (data) => ({ placements: Array.isArray(data) ? data : [] }), + data => ({ placements: Array.isArray(data) ? data : [] }), ); } diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts index ee71cc65..4e41f8b8 100644 --- a/lib/research/getResearchSearchHandler.ts +++ b/lib/research/getResearchSearchHandler.ts @@ -28,15 +28,6 @@ export async function getResearchSearchHandler(request: NextRequest): Promise { +export async function postResearchDeepHandler(request: NextRequest): Promise { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; const { accountId } = authResult; - let body: { query?: string }; + let body: z.infer; try { - body = await request.json(); - } catch { - return NextResponse.json( - { status: "error", error: "Invalid JSON body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - if (!body.query) { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; return NextResponse.json( - { status: "error", error: "query is required" }, + { status: "error", error: message ?? "Invalid request body" }, { status: 400, headers: getCorsHeaders() }, ); } - try { - await deductCredits({ accountId, creditsToDeduct: 25 }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } - try { const result = await chatWithPerplexity( [{ role: "user", content: body.query }], "sonar-deep-research", ); + try { + await deductCredits({ accountId, creditsToDeduct: 25 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + return NextResponse.json( { status: "success", diff --git a/lib/research/postResearchEnrichHandler.ts b/lib/research/postResearchEnrichHandler.ts index 8a5b6c7b..76060181 100644 --- a/lib/research/postResearchEnrichHandler.ts +++ b/lib/research/postResearchEnrichHandler.ts @@ -1,9 +1,16 @@ import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; import { enrichEntity } from "@/lib/parallel/enrichEntity"; +const bodySchema = z.object({ + input: z.string().min(1, "input is required"), + schema: z.record(z.string(), z.unknown()), + processor: z.enum(["base", "core", "ultra"]).optional().default("base"), +}); + /** * POST /api/research/enrich * @@ -12,34 +19,20 @@ import { enrichEntity } from "@/lib/parallel/enrichEntity"; * defining what fields to extract. Returns typed data with citations. * * @param request - Body: { input, schema, processor? } + * @returns JSON success or error response */ -export async function postResearchEnrichHandler( - request: NextRequest, -): Promise { +export async function postResearchEnrichHandler(request: NextRequest): Promise { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; const { accountId } = authResult; - let body: { input?: string; schema?: Record; processor?: "base" | "core" | "ultra" }; + let body: z.infer; try { - body = await request.json(); - } catch { - return NextResponse.json( - { status: "error", error: "Invalid JSON body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - if (!body.input) { - return NextResponse.json( - { status: "error", error: "input is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - if (!body.schema) { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; return NextResponse.json( - { status: "error", error: "schema is required (JSON schema defining output fields)" }, + { status: "error", error: message ?? "Invalid request body" }, { status: 400, headers: getCorsHeaders() }, ); } @@ -47,16 +40,7 @@ export async function postResearchEnrichHandler( const creditCost = body.processor === "ultra" ? 25 : body.processor === "core" ? 10 : 5; try { - await deductCredits({ accountId, creditsToDeduct: creditCost }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } - - try { - const result = await enrichEntity(body.input, body.schema, body.processor || "base"); + const result = await enrichEntity(body.input, body.schema, body.processor); if (result.status === "timeout") { return NextResponse.json( @@ -69,6 +53,12 @@ export async function postResearchEnrichHandler( ); } + try { + await deductCredits({ accountId, creditsToDeduct: creditCost }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + return NextResponse.json( { status: "success", diff --git a/lib/research/postResearchExtractHandler.ts b/lib/research/postResearchExtractHandler.ts index 5b41e4a6..525d5142 100644 --- a/lib/research/postResearchExtractHandler.ts +++ b/lib/research/postResearchExtractHandler.ts @@ -1,9 +1,16 @@ import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; import { extractUrl } from "@/lib/parallel/extractUrl"; +const bodySchema = z.object({ + urls: z.array(z.string().min(1)).min(1).max(10), + objective: z.string().optional(), + full_content: z.boolean().optional(), +}); + /** * POST /api/research/extract * @@ -11,50 +18,33 @@ import { extractUrl } from "@/lib/parallel/extractUrl"; * Handles JavaScript-heavy pages and PDFs. * * @param request - Body: { urls, objective?, full_content? } + * @returns JSON success or error response */ -export async function postResearchExtractHandler( - request: NextRequest, -): Promise { +export async function postResearchExtractHandler(request: NextRequest): Promise { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; const { accountId } = authResult; - let body: { urls?: string[]; objective?: string; full_content?: boolean }; + let body: z.infer; try { - body = await request.json(); - } catch { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; return NextResponse.json( - { status: "error", error: "Invalid JSON body" }, + { status: "error", error: message ?? "Invalid request body" }, { status: 400, headers: getCorsHeaders() }, ); } - if (!body.urls || !Array.isArray(body.urls) || body.urls.length === 0) { - return NextResponse.json( - { status: "error", error: "urls array is required (max 10)" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - if (body.urls.length > 10) { - return NextResponse.json( - { status: "error", error: "Maximum 10 URLs per request" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - try { - await deductCredits({ accountId, creditsToDeduct: 5 * body.urls.length }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } - try { const result = await extractUrl(body.urls, body.objective, body.full_content); + try { + await deductCredits({ accountId, creditsToDeduct: 5 * body.urls.length }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + return NextResponse.json( { status: "success", diff --git a/lib/research/postResearchPeopleHandler.ts b/lib/research/postResearchPeopleHandler.ts index 60135249..0cc1c2d6 100644 --- a/lib/research/postResearchPeopleHandler.ts +++ b/lib/research/postResearchPeopleHandler.ts @@ -1,9 +1,15 @@ import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; import { searchPeople } from "@/lib/exa/searchPeople"; +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), + num_results: z.coerce.number().int().min(1).max(100).optional(), +}); + /** * POST /api/research/people * @@ -12,42 +18,32 @@ import { searchPeople } from "@/lib/exa/searchPeople"; * including LinkedIn profiles. * * @param request - Body: { query, num_results? } + * @returns JSON success or error response */ -export async function postResearchPeopleHandler( - request: NextRequest, -): Promise { +export async function postResearchPeopleHandler(request: NextRequest): Promise { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; const { accountId } = authResult; - let body: { query?: string; num_results?: number }; + let body: z.infer; try { - body = await request.json(); - } catch { - return NextResponse.json( - { status: "error", error: "Invalid JSON body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - if (!body.query) { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; return NextResponse.json( - { status: "error", error: "query is required" }, + { status: "error", error: message ?? "Invalid request body" }, { status: 400, headers: getCorsHeaders() }, ); } try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } + const result = await searchPeople(body.query, body.num_results ?? 10); - try { - const result = await searchPeople(body.query, body.num_results || 10); + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } return NextResponse.json( { diff --git a/lib/research/postResearchWebHandler.ts b/lib/research/postResearchWebHandler.ts index 0247cb2b..47b6014f 100644 --- a/lib/research/postResearchWebHandler.ts +++ b/lib/research/postResearchWebHandler.ts @@ -1,10 +1,17 @@ import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { deductCredits } from "@/lib/credits/deductCredits"; import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), + max_results: z.coerce.number().int().min(1).max(20).optional(), + country: z.string().length(2).optional(), +}); + /** * POST /api/research/web * @@ -12,50 +19,40 @@ import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResu * with titles, URLs, and content snippets. * * @param request - Body: { query, max_results?, country? } + * @returns JSON success or error response */ -export async function postResearchWebHandler( - request: NextRequest, -): Promise { +export async function postResearchWebHandler(request: NextRequest): Promise { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; const { accountId } = authResult; - let body: { query?: string; max_results?: number; country?: string }; + let body: z.infer; try { - body = await request.json(); - } catch { - return NextResponse.json( - { status: "error", error: "Invalid JSON body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - if (!body.query) { + body = bodySchema.parse(await request.json()); + } catch (err) { + const message = err instanceof z.ZodError ? err.issues[0]?.message : "Invalid request body"; return NextResponse.json( - { status: "error", error: "query is required" }, + { status: "error", error: message ?? "Invalid request body" }, { status: 400, headers: getCorsHeaders() }, ); } - try { - await deductCredits({ accountId, creditsToDeduct: 5 }); - } catch { - return NextResponse.json( - { status: "error", error: "Insufficient credits" }, - { status: 402, headers: getCorsHeaders() }, - ); - } - try { const searchResponse = await searchPerplexity({ query: body.query, - max_results: body.max_results || 10, + max_results: body.max_results ?? 10, max_tokens_per_page: 1024, ...(body.country && { country: body.country }), }); const formatted = formatSearchResultsAsMarkdown(searchResponse); + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + return NextResponse.json( { status: "success",