From 6d1f80557303a9a2981a7fe0e4977ba6b6486bd9 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:26:02 -0400 Subject: [PATCH 1/7] feat: add 30 research API endpoints, 28 MCP tools, Zod validation, and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research primitive — provider-agnostic music industry research: - 30 REST endpoints under /api/research/ (Chartmetric, Perplexity, Exa, Parallel) - 28 MCP tools with proper auth (resolveAccountId) and credit deduction - 2 shared handlers (handleArtistResearch, handleResearchRequest) for DRY - Zod validation on discover endpoint - 10 test files (token, proxy, artist resolution, charts, lookup, similar, search, track, web, discover) - Source param allowlist on metrics to prevent path injection - proxyToChartmetric wrapped in try/catch for consistent error contract - All 1767 tests passing, 0 lint errors in research files Made-with: Cursor --- app/api/research/albums/route.ts | 22 +++ app/api/research/audience/route.ts | 22 +++ app/api/research/career/route.ts | 22 +++ app/api/research/charts/route.ts | 22 +++ app/api/research/cities/route.ts | 22 +++ app/api/research/curator/route.ts | 22 +++ app/api/research/deep/route.ts | 22 +++ app/api/research/discover/route.ts | 22 +++ app/api/research/enrich/route.ts | 22 +++ app/api/research/extract/route.ts | 22 +++ app/api/research/festivals/route.ts | 22 +++ app/api/research/genres/route.ts | 22 +++ app/api/research/insights/route.ts | 22 +++ app/api/research/instagram-posts/route.ts | 22 +++ app/api/research/lookup/route.ts | 22 +++ app/api/research/metrics/route.ts | 22 +++ app/api/research/milestones/route.ts | 22 +++ app/api/research/people/route.ts | 22 +++ app/api/research/playlist/route.ts | 22 +++ app/api/research/playlists/route.ts | 22 +++ app/api/research/profile/route.ts | 22 +++ app/api/research/radio/route.ts | 22 +++ app/api/research/rank/route.ts | 22 +++ app/api/research/route.ts | 22 +++ app/api/research/similar/route.ts | 22 +++ app/api/research/track/route.ts | 22 +++ app/api/research/tracks/route.ts | 22 +++ app/api/research/urls/route.ts | 22 +++ app/api/research/venues/route.ts | 22 +++ app/api/research/web/route.ts | 22 +++ .../__tests__/getChartmetricToken.test.ts | 77 ++++++++++ lib/chartmetric/getChartmetricToken.ts | 60 ++++++++ lib/exa/searchPeople.ts | 66 +++++++++ lib/mcp/tools/index.ts | 2 + lib/mcp/tools/research/index.ts | 64 ++++++++ .../research/registerResearchAlbumsTool.ts | 67 +++++++++ .../research/registerResearchArtistTool.ts | 66 +++++++++ .../research/registerResearchAudienceTool.ts | 73 +++++++++ .../research/registerResearchCareerTool.ts | 64 ++++++++ .../research/registerResearchChartsTool.ts | 79 ++++++++++ .../research/registerResearchCitiesTool.ts | 81 ++++++++++ .../research/registerResearchCuratorTool.ts | 66 +++++++++ .../research/registerResearchDiscoverTool.ts | 79 ++++++++++ .../research/registerResearchEnrichTool.ts | 69 +++++++++ .../research/registerResearchExtractTool.ts | 61 ++++++++ .../research/registerResearchFestivalsTool.ts | 54 +++++++ .../research/registerResearchGenresTool.ts | 55 +++++++ .../research/registerResearchInsightsTool.ts | 67 +++++++++ .../registerResearchInstagramPostsTool.ts | 65 +++++++++ .../research/registerResearchLookupTool.ts | 62 ++++++++ .../research/registerResearchMetricsTool.ts | 71 +++++++++ .../registerResearchMilestonesTool.ts | 66 +++++++++ .../research/registerResearchPeopleTool.ts | 60 ++++++++ .../research/registerResearchPlaylistTool.ts | 88 +++++++++++ .../research/registerResearchPlaylistsTool.ts | 110 ++++++++++++++ .../research/registerResearchRadioTool.ts | 57 ++++++++ .../research/registerResearchRankTool.ts | 63 ++++++++ .../research/registerResearchSearchTool.ts | 71 +++++++++ .../research/registerResearchSimilarTool.ts | 94 ++++++++++++ .../research/registerResearchTrackTool.ts | 70 +++++++++ .../research/registerResearchTracksTool.ts | 67 +++++++++ .../research/registerResearchUrlsTool.ts | 62 ++++++++ .../research/registerResearchVenuesTool.ts | 66 +++++++++ lib/parallel/enrichEntity.ts | 95 ++++++++++++ lib/parallel/extractUrl.ts | 60 ++++++++ .../getResearchChartsHandler.test.ts | 83 +++++++++++ .../getResearchDiscoverHandler.test.ts | 138 ++++++++++++++++++ .../getResearchLookupHandler.test.ts | 91 ++++++++++++ .../getResearchMetricsHandler.test.ts | 81 ++++++++++ .../getResearchSearchHandler.test.ts | 71 +++++++++ .../getResearchSimilarHandler.test.ts | 87 +++++++++++ .../__tests__/getResearchTrackHandler.test.ts | 76 ++++++++++ .../__tests__/postResearchWebHandler.test.ts | 83 +++++++++++ .../__tests__/proxyToChartmetric.test.ts | 93 ++++++++++++ lib/research/__tests__/resolveArtist.test.ts | 81 ++++++++++ lib/research/getResearchAlbumsHandler.ts | 20 +++ lib/research/getResearchAudienceHandler.ts | 19 +++ lib/research/getResearchCareerHandler.ts | 20 +++ lib/research/getResearchChartsHandler.ts | 44 ++++++ lib/research/getResearchCitiesHandler.ts | 33 +++++ lib/research/getResearchCuratorHandler.ts | 26 ++++ lib/research/getResearchDiscoverHandler.ts | 42 ++++++ lib/research/getResearchFestivalsHandler.ts | 19 +++ lib/research/getResearchGenresHandler.ts | 19 +++ lib/research/getResearchInsightsHandler.ts | 21 +++ .../getResearchInstagramPostsHandler.ts | 16 ++ lib/research/getResearchLookupHandler.ts | 70 +++++++++ lib/research/getResearchMetricsHandler.ts | 51 +++++++ lib/research/getResearchMilestonesHandler.ts | 20 +++ lib/research/getResearchPlaylistHandler.ts | 91 ++++++++++++ lib/research/getResearchPlaylistsHandler.ts | 61 ++++++++ lib/research/getResearchProfileHandler.ts | 15 ++ lib/research/getResearchRadioHandler.ts | 19 +++ lib/research/getResearchRankHandler.ts | 19 +++ lib/research/getResearchSearchHandler.ts | 52 +++++++ lib/research/getResearchSimilarHandler.ts | 36 +++++ lib/research/getResearchTrackHandler.ts | 76 ++++++++++ lib/research/getResearchTracksHandler.ts | 20 +++ lib/research/getResearchUrlsHandler.ts | 28 ++++ lib/research/getResearchVenuesHandler.ts | 19 +++ lib/research/handleArtistResearch.ts | 73 +++++++++ lib/research/handleResearchRequest.ts | 57 ++++++++ lib/research/postResearchDeepHandler.ts | 63 ++++++++ lib/research/postResearchEnrichHandler.ts | 79 ++++++++++ lib/research/postResearchExtractHandler.ts | 65 +++++++++ lib/research/postResearchPeopleHandler.ts | 64 ++++++++ lib/research/postResearchWebHandler.ts | 70 +++++++++ lib/research/proxyToChartmetric.ts | 57 ++++++++ lib/research/resolveArtist.ts | 53 +++++++ lib/research/validateDiscoverQuery.ts | 47 ++++++ 110 files changed, 5405 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/charts/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/deep/route.ts create mode 100644 app/api/research/discover/route.ts create mode 100644 app/api/research/enrich/route.ts create mode 100644 app/api/research/extract/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/milestones/route.ts create mode 100644 app/api/research/people/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/radio/route.ts create mode 100644 app/api/research/rank/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 app/api/research/venues/route.ts create mode 100644 app/api/research/web/route.ts create mode 100644 lib/chartmetric/__tests__/getChartmetricToken.test.ts create mode 100644 lib/chartmetric/getChartmetricToken.ts create mode 100644 lib/exa/searchPeople.ts create mode 100644 lib/mcp/tools/research/index.ts create mode 100644 lib/mcp/tools/research/registerResearchAlbumsTool.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/registerResearchCareerTool.ts create mode 100644 lib/mcp/tools/research/registerResearchChartsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchCitiesTool.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/registerResearchEnrichTool.ts create mode 100644 lib/mcp/tools/research/registerResearchExtractTool.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/registerResearchMetricsTool.ts create mode 100644 lib/mcp/tools/research/registerResearchMilestonesTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPeopleTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPlaylistTool.ts create mode 100644 lib/mcp/tools/research/registerResearchPlaylistsTool.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/registerResearchSearchTool.ts create mode 100644 lib/mcp/tools/research/registerResearchSimilarTool.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 create mode 100644 lib/mcp/tools/research/registerResearchVenuesTool.ts create mode 100644 lib/parallel/enrichEntity.ts create mode 100644 lib/parallel/extractUrl.ts create mode 100644 lib/research/__tests__/getResearchChartsHandler.test.ts create mode 100644 lib/research/__tests__/getResearchDiscoverHandler.test.ts create mode 100644 lib/research/__tests__/getResearchLookupHandler.test.ts create mode 100644 lib/research/__tests__/getResearchMetricsHandler.test.ts create mode 100644 lib/research/__tests__/getResearchSearchHandler.test.ts create mode 100644 lib/research/__tests__/getResearchSimilarHandler.test.ts create mode 100644 lib/research/__tests__/getResearchTrackHandler.test.ts create mode 100644 lib/research/__tests__/postResearchWebHandler.test.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/getResearchChartsHandler.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/getResearchMilestonesHandler.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/getResearchRadioHandler.ts create mode 100644 lib/research/getResearchRankHandler.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/getResearchVenuesHandler.ts create mode 100644 lib/research/handleArtistResearch.ts create mode 100644 lib/research/handleResearchRequest.ts create mode 100644 lib/research/postResearchDeepHandler.ts create mode 100644 lib/research/postResearchEnrichHandler.ts create mode 100644 lib/research/postResearchExtractHandler.ts create mode 100644 lib/research/postResearchPeopleHandler.ts create mode 100644 lib/research/postResearchWebHandler.ts create mode 100644 lib/research/proxyToChartmetric.ts create mode 100644 lib/research/resolveArtist.ts create mode 100644 lib/research/validateDiscoverQuery.ts diff --git a/app/api/research/albums/route.ts b/app/api/research/albums/route.ts new file mode 100644 index 00000000..a5c539d4 --- /dev/null +++ b/app/api/research/albums/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchAlbumsHandler } from "@/lib/research/getResearchAlbumsHandler"; + +/** + * OPTIONS /api/research/albums — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/albums — Artist album discography with release dates. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON album list or error + */ +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..34cf9a00 --- /dev/null +++ b/app/api/research/audience/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchAudienceHandler } from "@/lib/research/getResearchAudienceHandler"; + +/** + * OPTIONS /api/research/audience — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/audience — Audience demographics by platform. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON audience demographics or error + */ +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..9844ddff --- /dev/null +++ b/app/api/research/career/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCareerHandler } from "@/lib/research/getResearchCareerHandler"; + +/** + * OPTIONS /api/research/career — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/career — Artist career history and milestones. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON career timeline or error + */ +export async function GET(request: NextRequest) { + return getResearchCareerHandler(request); +} diff --git a/app/api/research/charts/route.ts b/app/api/research/charts/route.ts new file mode 100644 index 00000000..c92c5b2f --- /dev/null +++ b/app/api/research/charts/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchChartsHandler } from "@/lib/research/getResearchChartsHandler"; + +/** + * OPTIONS /api/research/charts — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/charts — Global chart positions by platform and country. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON chart positions or error + */ +export async function GET(request: NextRequest) { + return getResearchChartsHandler(request); +} diff --git a/app/api/research/cities/route.ts b/app/api/research/cities/route.ts new file mode 100644 index 00000000..28920d38 --- /dev/null +++ b/app/api/research/cities/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCitiesHandler } from "@/lib/research/getResearchCitiesHandler"; + +/** + * OPTIONS /api/research/cities — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/cities — Geographic listening data for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON city-level listener data or error + */ +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..6f3c7668 --- /dev/null +++ b/app/api/research/curator/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCuratorHandler } from "@/lib/research/getResearchCuratorHandler"; + +/** + * OPTIONS /api/research/curator — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/curator — Playlist curator details. Requires `?platform=` and `?id=` query params. + * + * @param request - must include `platform` and `id` query params + * @returns JSON curator profile or error + */ +export async function GET(request: NextRequest) { + return getResearchCuratorHandler(request); +} diff --git a/app/api/research/deep/route.ts b/app/api/research/deep/route.ts new file mode 100644 index 00000000..1b8b9ff3 --- /dev/null +++ b/app/api/research/deep/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchDeepHandler } from "@/lib/research/postResearchDeepHandler"; + +/** + * OPTIONS /api/research/deep — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/deep — Deep, comprehensive research with citations. Body: `{ query }`. + * + * @param request - JSON body with `query` string + * @returns JSON research report with citations or error + */ +export async function POST(request: NextRequest) { + return postResearchDeepHandler(request); +} diff --git a/app/api/research/discover/route.ts b/app/api/research/discover/route.ts new file mode 100644 index 00000000..836a0a8a --- /dev/null +++ b/app/api/research/discover/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchDiscoverHandler } from "@/lib/research/getResearchDiscoverHandler"; + +/** + * OPTIONS /api/research/discover — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/discover — Discover artists by genre, country, and growth criteria. Supports `?genre=`, `?country=`, `?sort=`, `?limit=` filters. + * + * @param request - filter criteria via query params + * @returns JSON array of matching artists or error + */ +export async function GET(request: NextRequest) { + return getResearchDiscoverHandler(request); +} diff --git a/app/api/research/enrich/route.ts b/app/api/research/enrich/route.ts new file mode 100644 index 00000000..46cba9a2 --- /dev/null +++ b/app/api/research/enrich/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchEnrichHandler } from "@/lib/research/postResearchEnrichHandler"; + +/** + * OPTIONS /api/research/enrich — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/enrich — Enrich an entity with structured web research data. Body: `{ url, prompt? }`. + * + * @param request - JSON body with `url` and optional `prompt` + * @returns JSON enriched entity data or error + */ +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..a9f0c795 --- /dev/null +++ b/app/api/research/extract/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchExtractHandler } from "@/lib/research/postResearchExtractHandler"; + +/** + * OPTIONS /api/research/extract — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/extract — Extract clean markdown from URLs. Body: `{ urls }`. + * + * @param request - JSON body with `urls` array + * @returns JSON extracted markdown content or error + */ +export async function POST(request: NextRequest) { + return postResearchExtractHandler(request); +} diff --git a/app/api/research/festivals/route.ts b/app/api/research/festivals/route.ts new file mode 100644 index 00000000..6cd15d3c --- /dev/null +++ b/app/api/research/festivals/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchFestivalsHandler } from "@/lib/research/getResearchFestivalsHandler"; + +/** + * OPTIONS /api/research/festivals — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/festivals — List of music festivals. + * + * @param request - optional filter query params + * @returns JSON festival list or error + */ +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..c8a665ba --- /dev/null +++ b/app/api/research/genres/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchGenresHandler } from "@/lib/research/getResearchGenresHandler"; + +/** + * OPTIONS /api/research/genres — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/genres — All available genre IDs and names. + * + * @param request - no required query params + * @returns JSON genre list or error + */ +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..c0dc85a5 --- /dev/null +++ b/app/api/research/insights/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchInsightsHandler } from "@/lib/research/getResearchInsightsHandler"; + +/** + * OPTIONS /api/research/insights — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/insights — Noteworthy highlights and trending metrics for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON insights data or error + */ +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..4330e24d --- /dev/null +++ b/app/api/research/instagram-posts/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchInstagramPostsHandler } from "@/lib/research/getResearchInstagramPostsHandler"; + +/** + * OPTIONS /api/research/instagram-posts — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/instagram-posts — Recent Instagram posts for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON Instagram posts or error + */ +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..668e3f40 --- /dev/null +++ b/app/api/research/lookup/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchLookupHandler } from "@/lib/research/getResearchLookupHandler"; + +/** + * OPTIONS /api/research/lookup — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/lookup — Resolve a Spotify artist URL to cross-platform IDs. Requires `?url=` query param. + * + * @param request - must include `url` query param (Spotify URL) + * @returns JSON cross-platform IDs or error + */ +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..8cd2f12b --- /dev/null +++ b/app/api/research/metrics/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMetricsHandler } from "@/lib/research/getResearchMetricsHandler"; + +/** + * OPTIONS /api/research/metrics — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/metrics — Platform-specific streaming and social metrics. Requires `?artist=` and `?source=` query params. + * + * @param request - must include `artist` and `source` query params + * @returns JSON metrics data or error + */ +export async function GET(request: NextRequest) { + return getResearchMetricsHandler(request); +} diff --git a/app/api/research/milestones/route.ts b/app/api/research/milestones/route.ts new file mode 100644 index 00000000..63b2f47b --- /dev/null +++ b/app/api/research/milestones/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMilestonesHandler } from "@/lib/research/getResearchMilestonesHandler"; + +/** + * OPTIONS /api/research/milestones — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/milestones — Artist activity feed: playlist adds, chart entries, events. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON milestone activity feed or error + */ +export async function GET(request: NextRequest) { + return getResearchMilestonesHandler(request); +} diff --git a/app/api/research/people/route.ts b/app/api/research/people/route.ts new file mode 100644 index 00000000..ea8f121f --- /dev/null +++ b/app/api/research/people/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchPeopleHandler } from "@/lib/research/postResearchPeopleHandler"; + +/** + * OPTIONS /api/research/people — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/people — Search for people in the music industry. Body: `{ query, num_results? }`. + * + * @param request - JSON body with `query` string + * @returns JSON people results or error + */ +export async function POST(request: NextRequest) { + return postResearchPeopleHandler(request); +} diff --git a/app/api/research/playlist/route.ts b/app/api/research/playlist/route.ts new file mode 100644 index 00000000..db8e8c06 --- /dev/null +++ b/app/api/research/playlist/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchPlaylistHandler } from "@/lib/research/getResearchPlaylistHandler"; + +/** + * OPTIONS /api/research/playlist — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/playlist — Details for a specific playlist. Requires `?platform=` and `?id=` query params. + * + * @param request - must include `platform` and `id` query params + * @returns JSON playlist details or error + */ +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..1df1615b --- /dev/null +++ b/app/api/research/playlists/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchPlaylistsHandler } from "@/lib/research/getResearchPlaylistsHandler"; + +/** + * OPTIONS /api/research/playlists — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/playlists — Playlists featuring an artist on a given platform. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON playlist placements or error + */ +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..d5c9d707 --- /dev/null +++ b/app/api/research/profile/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchProfileHandler } from "@/lib/research/getResearchProfileHandler"; + +/** + * OPTIONS /api/research/profile — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/profile — Full artist profile with bio, genres, social URLs, and label info. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON artist profile or error + */ +export async function GET(request: NextRequest) { + return getResearchProfileHandler(request); +} diff --git a/app/api/research/radio/route.ts b/app/api/research/radio/route.ts new file mode 100644 index 00000000..351c52a2 --- /dev/null +++ b/app/api/research/radio/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchRadioHandler } from "@/lib/research/getResearchRadioHandler"; + +/** + * OPTIONS /api/research/radio — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/radio — List of radio stations. + * + * @param request - optional filter query params + * @returns JSON radio station list or error + */ +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..87c1768d --- /dev/null +++ b/app/api/research/rank/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchRankHandler } from "@/lib/research/getResearchRankHandler"; + +/** + * OPTIONS /api/research/rank — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/rank — Artist's global ranking data. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON ranking data or error + */ +export async function GET(request: NextRequest) { + return getResearchRankHandler(request); +} diff --git a/app/api/research/route.ts b/app/api/research/route.ts new file mode 100644 index 00000000..5a2690cf --- /dev/null +++ b/app/api/research/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchSearchHandler } from "@/lib/research/getResearchSearchHandler"; + +/** + * OPTIONS /api/research — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research — Search for artists by name. Requires `?q=` query param. + * + * @param request - must include `q` query param + * @returns JSON search results or error + */ +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..78bea1c3 --- /dev/null +++ b/app/api/research/similar/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchSimilarHandler } from "@/lib/research/getResearchSimilarHandler"; + +/** + * OPTIONS /api/research/similar — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/similar — Similar artists by audience, genre, mood, or musicality. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON array of similar artists or error + */ +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..e8fce5ed --- /dev/null +++ b/app/api/research/track/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTrackHandler } from "@/lib/research/getResearchTrackHandler"; + +/** + * OPTIONS /api/research/track — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/track — Search for a track by name and return full details. Requires `?q=` query param. + * + * @param request - must include `q` query param + * @returns JSON track details or error + */ +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..fbb5f60f --- /dev/null +++ b/app/api/research/tracks/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTracksHandler } from "@/lib/research/getResearchTracksHandler"; + +/** + * OPTIONS /api/research/tracks — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/tracks — All tracks for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON track list or error + */ +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..e95a5ca6 --- /dev/null +++ b/app/api/research/urls/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchUrlsHandler } from "@/lib/research/getResearchUrlsHandler"; + +/** + * OPTIONS /api/research/urls — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/urls — All known platform URLs for an artist. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON platform URLs or error + */ +export async function GET(request: NextRequest) { + return getResearchUrlsHandler(request); +} diff --git a/app/api/research/venues/route.ts b/app/api/research/venues/route.ts new file mode 100644 index 00000000..2db8bfb3 --- /dev/null +++ b/app/api/research/venues/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchVenuesHandler } from "@/lib/research/getResearchVenuesHandler"; + +/** + * OPTIONS /api/research/venues — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/venues — Venues an artist has performed at. Requires `?artist=` query param. + * + * @param request - must include `artist` query param + * @returns JSON venue list or error + */ +export async function GET(request: NextRequest) { + return getResearchVenuesHandler(request); +} diff --git a/app/api/research/web/route.ts b/app/api/research/web/route.ts new file mode 100644 index 00000000..9362207b --- /dev/null +++ b/app/api/research/web/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchWebHandler } from "@/lib/research/postResearchWebHandler"; + +/** + * OPTIONS /api/research/web — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/web — Search the web for real-time information. Body: `{ query, max_results?, country? }`. + * + * @param request - JSON body with `query` string + * @returns JSON search results with formatted markdown or error + */ +export async function POST(request: NextRequest) { + return postResearchWebHandler(request); +} diff --git a/lib/chartmetric/__tests__/getChartmetricToken.test.ts b/lib/chartmetric/__tests__/getChartmetricToken.test.ts new file mode 100644 index 00000000..aa85699d --- /dev/null +++ b/lib/chartmetric/__tests__/getChartmetricToken.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getChartmetricToken, resetTokenCache } from "../getChartmetricToken"; + +describe("getChartmetricToken", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + resetTokenCache(); + 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 token on successful exchange", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ 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("caches the token and does not fetch again on second call", async () => { + process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ token: "cached-token", expires_in: 3600 }), + } as Response); + + const token1 = await getChartmetricToken(); + const token2 = await getChartmetricToken(); + + expect(token1).toBe("cached-token"); + expect(token2).toBe("cached-token"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws when response has no 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("token"); + }); +}); diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts new file mode 100644 index 00000000..8f26ec65 --- /dev/null +++ b/lib/chartmetric/getChartmetricToken.ts @@ -0,0 +1,60 @@ +let cachedToken: string | null = null; +let tokenExpiresAt = 0; + +/** + * Reset cached token — for testing only. + * + * @internal + */ +export function resetTokenCache(): void { + cachedToken = null; + tokenExpiresAt = 0; +} + +/** + * Exchanges the Chartmetric refresh token for a short-lived access token. + * Caches the token until 60 seconds before expiry to avoid redundant API calls. + * + * @returns The Chartmetric access token string. + * @throws Error if the token exchange fails or the env variable is missing. + */ +export async function getChartmetricToken(): Promise { + if (cachedToken && Date.now() < tokenExpiresAt) { + return cachedToken; + } + + 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 { + token?: string; + access_token?: string; + expires_in: number; + }; + + const token = data.token || data.access_token; + + if (!token) { + throw new Error("Chartmetric token response did not include a token"); + } + + cachedToken = token; + tokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000; + + return token; +} diff --git a/lib/exa/searchPeople.ts b/lib/exa/searchPeople.ts new file mode 100644 index 00000000..581b6122 --- /dev/null +++ b/lib/exa/searchPeople.ts @@ -0,0 +1,66 @@ +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 safeNumResults = Math.min(100, Math.max(1, Math.floor(numResults))); + 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: safeNumResults, + 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/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..423a767a --- /dev/null +++ b/lib/mcp/tools/research/index.ts @@ -0,0 +1,64 @@ +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"; +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 { registerResearchMilestonesTool } from "./registerResearchMilestonesTool"; +import { registerResearchVenuesTool } from "./registerResearchVenuesTool"; +import { registerResearchRankTool } from "./registerResearchRankTool"; +import { registerResearchChartsTool } from "./registerResearchChartsTool"; +import { registerResearchRadioTool } from "./registerResearchRadioTool"; +import { registerResearchSearchTool } from "./registerResearchSearchTool"; +/** + * 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); + 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); + registerResearchMilestonesTool(server); + registerResearchVenuesTool(server); + registerResearchRankTool(server); + registerResearchChartsTool(server); + registerResearchRadioTool(server); + registerResearchSearchTool(server); +}; diff --git a/lib/mcp/tools/research/registerResearchAlbumsTool.ts b/lib/mcp/tools/research/registerResearchAlbumsTool.ts new file mode 100644 index 00000000..806d49f9 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchAlbumsTool.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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "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.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/albums`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const data = result.data; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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/registerResearchArtistTool.ts b/lib/mcp/tools/research/registerResearchArtistTool.ts new file mode 100644 index 00000000..a7402cc1 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchArtistTool.ts @@ -0,0 +1,66 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "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.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + + 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..544766e3 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchAudienceTool.ts @@ -0,0 +1,73 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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`, + ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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/registerResearchCareerTool.ts b/lib/mcp/tools/research/registerResearchCareerTool.ts new file mode 100644 index 00000000..bcdea6fa --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCareerTool.ts @@ -0,0 +1,64 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_career", + { + description: + "Get an artist's career timeline — key milestones, trajectory, and career stage.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/career`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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/registerResearchChartsTool.ts b/lib/mcp/tools/research/registerResearchChartsTool.ts new file mode 100644 index 00000000..6023d22f --- /dev/null +++ b/lib/mcp/tools/research/registerResearchChartsTool.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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_chart_positions", + { + 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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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; + 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); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch charts", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchCitiesTool.ts b/lib/mcp/tools/research/registerResearchCitiesTool.ts new file mode 100644 index 00000000..720a9293 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCitiesTool.ts @@ -0,0 +1,81 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + 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>; + } + )?.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); + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ cities }); + } catch (error) { + return getToolResultError( + 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 new file mode 100644 index 00000000..93de2a18 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCuratorTool.ts @@ -0,0 +1,66 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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"), +}); + +/** + * 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( + "get_curator_info", + { + description: + "Get curator profile — who curates a playlist, their other playlists, and follower reach.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + 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}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..c43dff13 --- /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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "discover_artists", + { + description: + "Discover artists by criteria — filter by country, genre, listener count, follower count, and growth rate.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to discover artists", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchEnrichTool.ts b/lib/mcp/tools/research/registerResearchEnrichTool.ts new file mode 100644 index 00000000..b296a326 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchEnrichTool.ts @@ -0,0 +1,69 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + input: z.string().describe("What to research"), + schema: z + .record(z.string(), 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( + "enrich_entity", + { + 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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await enrichEntity( + args.input, + args.schema as Record, + args.processor ?? "base", + ); + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..8f5210eb --- /dev/null +++ b/lib/mcp/tools/research/registerResearchExtractTool.ts @@ -0,0 +1,61 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "extract_url_content", + { + 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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await extractUrl(args.urls, args.objective, args.full_content ?? false); + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to extract URL content", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchFestivalsTool.ts b/lib/mcp/tools/research/registerResearchFestivalsTool.ts new file mode 100644 index 00000000..d57af48d --- /dev/null +++ b/lib/mcp/tools/research/registerResearchFestivalsTool.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 { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_festivals", + { + description: "List music festivals.", + inputSchema: schema, + }, + async (_args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await proxyToChartmetric("/festival/list"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..f239d549 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchGenresTool.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 { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_genres", + { + description: + "List all available genre IDs and names. Use these IDs with the research_discover tool.", + inputSchema: schema, + }, + async (_args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await proxyToChartmetric("/genres"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..1590ffef --- /dev/null +++ b/lib/mcp/tools/research/registerResearchInsightsTool.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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_insights", + { + description: + "Get AI-generated insights about an artist — automatically surfaced trends, milestones, and observations.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + 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; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..08e1a268 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchInstagramPostsTool.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 { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_instagram_posts", + { + description: "Get an artist's top Instagram posts and reels sorted by engagement.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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`, + ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..b9f39178 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchLookupTool.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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "lookup_artist_by_url", + { + description: "Look up an artist by a Spotify URL or platform ID. Returns the artist profile.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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/registerResearchMetricsTool.ts b/lib/mcp/tools/research/registerResearchMetricsTool.ts new file mode 100644 index 00000000..c8302633 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchMetricsTool.ts @@ -0,0 +1,71 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/stat/${args.source}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch metrics", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchMilestonesTool.ts b/lib/mcp/tools/research/registerResearchMilestonesTool.ts new file mode 100644 index 00000000..a7195c2a --- /dev/null +++ b/lib/mcp/tools/research/registerResearchMilestonesTool.ts @@ -0,0 +1,66 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + 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 Record)?.insights || []; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ milestones }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch milestones", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPeopleTool.ts b/lib/mcp/tools/research/registerResearchPeopleTool.ts new file mode 100644 index 00000000..066678ad --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPeopleTool.ts @@ -0,0 +1,60 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "find_industry_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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await searchPeople(args.query, args.num_results ?? 10); + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to search for people", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPlaylistTool.ts b/lib/mcp/tools/research/registerResearchPlaylistTool.ts new file mode 100644 index 00000000..6c3423dc --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPlaylistTool.ts @@ -0,0 +1,88 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + +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( + "get_playlist_info", + { + description: + "Get playlist metadata — name, description, follower count, track count, and curator info.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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)) { + const searchResult = await proxyToChartmetric("/search", { + q: numericId, + 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}"`); + } + + numericId = String((playlists[0] as Record).id); + } + + const result = await proxyToChartmetric(`/playlist/${args.platform}/${numericId}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + 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 new file mode 100644 index 00000000..802976f3 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts @@ -0,0 +1,110 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + +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( + "get_artist_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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + 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 = {}; + 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, + ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + const data = result.data; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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/registerResearchRadioTool.ts b/lib/mcp/tools/research/registerResearchRadioTool.ts new file mode 100644 index 00000000..f8c4a969 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchRadioTool.ts @@ -0,0 +1,57 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_radio_stations", + { + description: + "List radio stations tracked by Chartmetric. " + + "Returns station names, formats, and markets.", + inputSchema: schema, + }, + async (_args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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 : []; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..93f567e3 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchRankTool.ts @@ -0,0 +1,63 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_rank", + { + description: + "Get an artist's global Chartmetric ranking. " + "Returns a single integer rank value.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + 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 Record)?.artist_rank || null; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ rank }); + } catch (error) { + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch rank"); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchSearchTool.ts b/lib/mcp/tools/research/registerResearchSearchTool.ts new file mode 100644 index 00000000..f1e700b9 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchSearchTool.ts @@ -0,0 +1,71 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const schema = z.object({ + query: z.string().describe("Search query — artist name, track title, or keyword"), + type: z + .string() + .optional() + .describe("Result type: artists, tracks, or albums (default: artists)"), + limit: z.string().optional().describe("Max results to return (default: 10)"), +}); + +/** + * Registers the "research_search" tool on the MCP server. + * Searches Chartmetric for artists, tracks, or albums by keyword. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchSearchTool(server: McpServer): void { + server.registerTool( + "search_artists", + { + description: + "Search for music artists, tracks, or albums by keyword. Returns matching results with profile summaries.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const result = await proxyToChartmetric("/search", { + q: args.query, + type: args.type || "artists", + limit: args.limit || "10", + }); + + if (result.status !== 200) { + return getToolResultError(`Search failed with status ${result.status}`); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + + const data = result.data as { artists?: unknown[]; tracks?: unknown[]; albums?: unknown[] }; + const results = data?.artists || data?.tracks || data?.albums || []; + + return getToolResultSuccess({ results }); + } catch (error) { + return getToolResultError(error instanceof Error ? error.message : "Search failed"); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchSimilarTool.ts b/lib/mcp/tools/research/registerResearchSimilarTool.ts new file mode 100644 index 00000000..90d93785 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchSimilarTool.ts @@ -0,0 +1,94 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_similar_artists", + { + 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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + const data = result.data as Record; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchTrackTool.ts b/lib/mcp/tools/research/registerResearchTrackTool.ts new file mode 100644 index 00000000..6e3b9653 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTrackTool.ts @@ -0,0 +1,70 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_track_info", + { + description: "Get track metadata — title, artist, album, release date, and popularity.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + 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 searchData = searchResult.data as { tracks?: Array<{ id: number }> }; + const tracks = searchData?.tracks; + if (!Array.isArray(tracks) || tracks.length === 0) { + return getToolResultError(`No track found for "${args.q}"`); + } + + const trackId = tracks[0].id; + const result = await proxyToChartmetric(`/track/${trackId}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..fdc2b9b3 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTracksTool.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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_tracks", + { + description: + "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, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/tracks`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const data = result.data; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + 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..fa9d9906 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchUrlsTool.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 { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "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.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/urls`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess(result.data); + } catch (error) { + 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 new file mode 100644 index 00000000..22c6d7dd --- /dev/null +++ b/lib/mcp/tools/research/registerResearchVenuesTool.ts @@ -0,0 +1,66 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +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( + "get_artist_venues", + { + description: + "Get venues an artist has performed at. " + + "Includes venue name, capacity, city, country, and event history.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + 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 : []; + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + return getToolResultSuccess({ venues }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch venues", + ); + } + }, + ); +} diff --git a/lib/parallel/enrichEntity.ts b/lib/parallel/enrichEntity.ts new file mode 100644 index 00000000..23765abc --- /dev/null +++ b/lib/parallel/enrichEntity.ts @@ -0,0 +1,95 @@ +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) + * @returns The enrichment result with structured output and optional citations. + */ +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..f53748e2 --- /dev/null +++ b/lib/parallel/extractUrl.ts @@ -0,0 +1,60 @@ +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 + * @returns The extraction response with results and any errors. + */ +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/__tests__/getResearchChartsHandler.test.ts b/lib/research/__tests__/getResearchChartsHandler.test.ts new file mode 100644 index 00000000..5ecf99e6 --- /dev/null +++ b/lib/research/__tests__/getResearchChartsHandler.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchChartsHandler } from "../getResearchChartsHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchChartsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + }); + + it("returns 400 when platform is missing", async () => { + const req = new NextRequest("http://localhost/api/research/charts"); + const res = await getResearchChartsHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when platform contains path traversal", async () => { + const req = new NextRequest("http://localhost/api/research/charts?platform=../admin"); + const res = await getResearchChartsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid platform"); + }); + + it("returns 400 when platform contains slashes", async () => { + const req = new NextRequest("http://localhost/api/research/charts?platform=foo/bar"); + const res = await getResearchChartsHandler(req); + expect(res.status).toBe(400); + }); + + it("defaults type to 'regional' and interval to 'daily'", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { chart: [] }, + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&country=US"); + await getResearchChartsHandler(req); + + const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + expect(calledParams).toHaveProperty("type", "regional"); + expect(calledParams).toHaveProperty("interval", "daily"); + expect(calledParams).toHaveProperty("country_code", "US"); + }); + + it("preserves user-provided type and interval", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { chart: [] }, + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/charts?platform=spotify&type=viral&interval=weekly&country=US", + ); + await getResearchChartsHandler(req); + + const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + expect(calledParams).toMatchObject({ type: "viral", interval: "weekly" }); + }); +}); diff --git a/lib/research/__tests__/getResearchDiscoverHandler.test.ts b/lib/research/__tests__/getResearchDiscoverHandler.test.ts new file mode 100644 index 00000000..783301c4 --- /dev/null +++ b/lib/research/__tests__/getResearchDiscoverHandler.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchDiscoverHandler } from "../getResearchDiscoverHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchDiscoverHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research/discover?country=US"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when country is not 2 letters", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + const req = new NextRequest("http://localhost/api/research/discover?country=USA"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.status).toBe("error"); + expect(body.error).toContain("2-letter"); + }); + + it("returns 400 when limit is negative", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + const req = new NextRequest("http://localhost/api/research/discover?limit=-5"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(400); + }); + + it("returns artists on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [ + { name: "Artist A", sp_monthly_listeners: 100000 }, + { name: "Artist B", sp_monthly_listeners: 200000 }, + ], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/discover?country=US&limit=10"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.artists).toHaveLength(2); + expect(body.artists[0].name).toBe("Artist A"); + }); + + it("passes sp_ml range when both min and max provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [], + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/discover?sp_monthly_listeners_min=50000&sp_monthly_listeners_max=200000", + ); + await getResearchDiscoverHandler(req); + + expect(proxyToChartmetric).toHaveBeenCalledWith( + "/artist/list/filter", + expect.objectContaining({ "sp_ml[]": "50000,200000" }), + ); + }); + + it("returns empty array when proxy fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "tok", + } as ReturnType extends Promise + ? Exclude + : never); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: null, + status: 500, + }); + + const req = new NextRequest("http://localhost/api/research/discover?country=US"); + const res = await getResearchDiscoverHandler(req); + expect(res.status).toBe(500); + }); +}); diff --git a/lib/research/__tests__/getResearchLookupHandler.test.ts b/lib/research/__tests__/getResearchLookupHandler.test.ts new file mode 100644 index 00000000..0a47b458 --- /dev/null +++ b/lib/research/__tests__/getResearchLookupHandler.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchLookupHandler } from "../getResearchLookupHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchLookupHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + }); + + it("returns 400 when url is missing", async () => { + const req = new NextRequest("http://localhost/api/research/lookup"); + const res = await getResearchLookupHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when url is not a Spotify artist URL", async () => { + const req = new NextRequest("http://localhost/api/research/lookup?url=https://google.com"); + const res = await getResearchLookupHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Spotify artist URL"); + }); + + it("wraps array responses in a data field instead of spreading indices", async () => { + const arrayData = [ + { id: 1, platform: "spotify" }, + { id: 2, platform: "apple_music" }, + ]; + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: arrayData, + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", + ); + const res = await getResearchLookupHandler(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.status).toBe("success"); + // Should wrap in data field, NOT spread as {"0":...,"1":...} + expect(body.data).toEqual(arrayData); + expect(body).not.toHaveProperty("0"); + }); + + it("spreads object responses normally", async () => { + const objectData = { id: 3380, spotify_id: "3TVXtAsR1Inumwj472S9r4" }; + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: objectData, + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", + ); + const res = await getResearchLookupHandler(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.id).toBe(3380); + expect(body.spotify_id).toBe("3TVXtAsR1Inumwj472S9r4"); + }); +}); diff --git a/lib/research/__tests__/getResearchMetricsHandler.test.ts b/lib/research/__tests__/getResearchMetricsHandler.test.ts new file mode 100644 index 00000000..709b8bdd --- /dev/null +++ b/lib/research/__tests__/getResearchMetricsHandler.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchMetricsHandler } from "../getResearchMetricsHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/resolveArtist", () => ({ + resolveArtist: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchMetricsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when source is missing", async () => { + const req = new NextRequest("http://localhost/api/research/metrics?artist=Drake"); + const res = await getResearchMetricsHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when source contains path traversal characters", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/metrics?artist=Drake&source=../admin", + ); + const res = await getResearchMetricsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid source"); + }); + + it("returns 400 when source contains slashes", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/metrics?artist=Drake&source=foo/bar", + ); + const res = await getResearchMetricsHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when source contains encoded slashes", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/metrics?artist=Drake&source=foo%2fbar", + ); + const res = await getResearchMetricsHandler(req); + expect(res.status).toBe(400); + }); +}); diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts new file mode 100644 index 00000000..35528954 --- /dev/null +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchSearchHandler } from "../getResearchSearchHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchSearchHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research?q=Drake"); + const res = await getResearchSearchHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when q param is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest("http://localhost/api/research"); + const res = await getResearchSearchHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("q parameter is required"); + }); + + it("returns 200 with results on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { artists: [{ name: "Drake", id: 3380 }] }, + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research?q=Drake"); + const res = await getResearchSearchHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.results).toEqual([{ name: "Drake", id: 3380 }]); + }); +}); diff --git a/lib/research/__tests__/getResearchSimilarHandler.test.ts b/lib/research/__tests__/getResearchSimilarHandler.test.ts new file mode 100644 index 00000000..b9230e2b --- /dev/null +++ b/lib/research/__tests__/getResearchSimilarHandler.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchSimilarHandler } from "../getResearchSimilarHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/resolveArtist", () => ({ + resolveArtist: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchSimilarHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + vi.mocked(resolveArtist).mockResolvedValue({ id: 3380 }); + }); + + it("uses by-configurations path with default params when no config params provided", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [{ id: 100, name: "Kendrick Lamar" }], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/similar?artist=Drake"); + const res = await getResearchSimilarHandler(req); + expect(res.status).toBe(200); + + // Should call by-configurations, NOT relatedartists + const calledPath = vi.mocked(proxyToChartmetric).mock.calls[0][0]; + expect(calledPath).toContain("by-configurations"); + expect(calledPath).not.toContain("relatedartists"); + }); + + it("uses by-configurations path when config params are provided", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [{ id: 100, name: "Kendrick Lamar" }], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/similar?artist=Drake&genre=high"); + const res = await getResearchSimilarHandler(req); + expect(res.status).toBe(200); + + const calledPath = vi.mocked(proxyToChartmetric).mock.calls[0][0]; + expect(calledPath).toContain("by-configurations"); + }); + + it("passes default medium values for config params when none provided", async () => { + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [], + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/similar?artist=Drake"); + await getResearchSimilarHandler(req); + + const calledParams = vi.mocked(proxyToChartmetric).mock.calls[0][1]; + expect(calledParams).toMatchObject({ + audience: "medium", + genre: "medium", + mood: "medium", + musicality: "medium", + }); + }); +}); diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts new file mode 100644 index 00000000..c1ffe96b --- /dev/null +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchTrackHandler } from "../getResearchTrackHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchTrackHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); + const res = await getResearchTrackHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when q param is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest("http://localhost/api/research/track"); + const res = await getResearchTrackHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("q parameter is required"); + }); + + it("returns 200 with track data on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric) + .mockResolvedValueOnce({ + data: { tracks: [{ id: 12345 }] }, + status: 200, + }) + .mockResolvedValueOnce({ + data: { name: "Hotline Bling", artist: "Drake", id: 12345 }, + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/track?q=Hotline+Bling"); + const res = await getResearchTrackHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.name).toBe("Hotline Bling"); + }); +}); diff --git a/lib/research/__tests__/postResearchWebHandler.test.ts b/lib/research/__tests__/postResearchWebHandler.test.ts new file mode 100644 index 00000000..c7403e84 --- /dev/null +++ b/lib/research/__tests__/postResearchWebHandler.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { postResearchWebHandler } from "../postResearchWebHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; +import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/perplexity/searchPerplexity", () => ({ + searchPerplexity: vi.fn(), +})); + +vi.mock("@/lib/perplexity/formatSearchResultsAsMarkdown", () => ({ + formatSearchResultsAsMarkdown: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("postResearchWebHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research/web", { + method: "POST", + body: JSON.stringify({ query: "test" }), + }); + const res = await postResearchWebHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when body is missing query", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest("http://localhost/api/research/web", { + method: "POST", + body: JSON.stringify({}), + }); + const res = await postResearchWebHandler(req); + expect(res.status).toBe(400); + }); + + it("returns 200 with results and formatted markdown on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const mockResults = [{ title: "Test", url: "https://example.com", snippet: "..." }]; + vi.mocked(searchPerplexity).mockResolvedValue({ results: mockResults } as never); + vi.mocked(formatSearchResultsAsMarkdown).mockReturnValue("# Results\n..."); + + const req = new NextRequest("http://localhost/api/research/web", { + method: "POST", + body: JSON.stringify({ query: "latest music trends" }), + }); + const res = await postResearchWebHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.results).toEqual(mockResults); + expect(body.formatted).toBe("# Results\n..."); + }); +}); 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..97b9a5cf --- /dev/null +++ b/lib/research/getResearchAlbumsHandler.ts @@ -0,0 +1,20 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchAlbumsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/albums`, + undefined, + data => ({ albums: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchAudienceHandler.ts b/lib/research/getResearchAudienceHandler.ts new file mode 100644 index 00000000..3615b677 --- /dev/null +++ b/lib/research/getResearchAudienceHandler.ts @@ -0,0 +1,19 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +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..df8b9023 --- /dev/null +++ b/lib/research/getResearchCareerHandler.ts @@ -0,0 +1,20 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchCareerHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/career`, + undefined, + data => ({ career: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchChartsHandler.ts b/lib/research/getResearchChartsHandler.ts new file mode 100644 index 00000000..8de47afa --- /dev/null +++ b/lib/research/getResearchChartsHandler.ts @@ -0,0 +1,44 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/charts + * + * Returns global chart positions for a platform. Not artist-scoped. + * Requires `platform` query param. Optional: `country`, `interval`, `type`. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchChartsHandler(request: NextRequest) { + 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() }, + ); + } + + if (!/^[a-z]+$/.test(platform)) { + return NextResponse.json( + { status: "error", error: "Invalid platform parameter" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return handleResearchRequest( + request, + () => `/charts/${platform}`, + sp => { + const params: Record = {}; + params.country_code = sp.get("country") || "US"; + params.interval = sp.get("interval") || "daily"; + params.type = sp.get("type") || "regional"; + params.latest = sp.get("latest") ?? "true"; + return params; + }, + ); +} diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts new file mode 100644 index 00000000..d469cacc --- /dev/null +++ b/lib/research/getResearchCitiesHandler.ts @@ -0,0 +1,33 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchCitiesHandler(request: NextRequest) { + 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/getResearchCuratorHandler.ts b/lib/research/getResearchCuratorHandler.ts new file mode 100644 index 00000000..4c8d47bd --- /dev/null +++ b/lib/research/getResearchCuratorHandler.ts @@ -0,0 +1,26 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/curator + * + * Returns details for a specific playlist curator. + * + * @param request - Requires `platform` and `id` query params + * @returns The JSON response. + */ +export async function getResearchCuratorHandler(request: NextRequest) { + 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() }, + ); + } + + return handleResearchRequest(request, () => `/curator/${platform}/${id}`); +} diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts new file mode 100644 index 00000000..fee1ad97 --- /dev/null +++ b/lib/research/getResearchDiscoverHandler.ts @@ -0,0 +1,42 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; +import { validateDiscoverQuery } from "@/lib/research/validateDiscoverQuery"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * Discover handler — filters artists by country, genre, listener ranges, growth rate. + * + * @param request - query params: country, genre, sort, limit, sp_monthly_listeners_min/max + * @returns JSON artist list or error + */ +export async function getResearchDiscoverHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const validated = validateDiscoverQuery(searchParams); + + if (validated instanceof NextResponse) return validated; + + return handleResearchRequest( + request, + () => "/artist/list/filter", + () => { + const params: Record = {}; + if (validated.country) params.code2 = validated.country; + if (validated.genre) params.tagId = validated.genre; + if (validated.sort) params.sortColumn = validated.sort; + if (validated.limit) params.limit = String(validated.limit); + if ( + validated.sp_monthly_listeners_min !== undefined && + validated.sp_monthly_listeners_max !== undefined + ) { + params["sp_ml[]"] = + `${validated.sp_monthly_listeners_min},${validated.sp_monthly_listeners_max}`; + } else if (validated.sp_monthly_listeners_min !== undefined) { + params["sp_ml[]"] = String(validated.sp_monthly_listeners_min); + } else if (validated.sp_monthly_listeners_max !== undefined) { + params["sp_ml[]"] = String(validated.sp_monthly_listeners_max); + } + return params; + }, + data => ({ artists: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts new file mode 100644 index 00000000..f390e86c --- /dev/null +++ b/lib/research/getResearchFestivalsHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/festivals + * + * Returns a list of music festivals. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +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 new file mode 100644 index 00000000..14e32d41 --- /dev/null +++ b/lib/research/getResearchGenresHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/genres + * + * Returns all available genre IDs and names. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchGenresHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/genres", + undefined, + data => ({ genres: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchInsightsHandler.ts b/lib/research/getResearchInsightsHandler.ts new file mode 100644 index 00000000..b0cae4a5 --- /dev/null +++ b/lib/research/getResearchInsightsHandler.ts @@ -0,0 +1,21 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchInsightsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/noteworthy-insights`, + undefined, + data => ({ insights: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts new file mode 100644 index 00000000..93ca7bdc --- /dev/null +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -0,0 +1,16 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +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..f626d2f0 --- /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 + * @returns The JSON response. + */ +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]; + + 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() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + const responseData = result.data; + + return NextResponse.json( + { + status: "success", + ...(typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) + ? responseData + : { data: responseData }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts new file mode 100644 index 00000000..c5eb19de --- /dev/null +++ b/lib/research/getResearchMetricsHandler.ts @@ -0,0 +1,51 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +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() }, + ); + } + + const VALID_SOURCES = [ + "spotify", + "instagram", + "tiktok", + "twitter", + "facebook", + "youtube_channel", + "youtube_artist", + "soundcloud", + "deezer", + "twitch", + "line", + "melon", + "wikipedia", + "bandsintown", + ]; + + if (!VALID_SOURCES.includes(source)) { + return NextResponse.json( + { status: "error", error: `Invalid source. Must be one of: ${VALID_SOURCES.join(", ")}` }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return handleArtistResearch(request, cmId => `/artist/${cmId}/stat/${source}`); +} diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts new file mode 100644 index 00000000..10eb845c --- /dev/null +++ b/lib/research/getResearchMilestonesHandler.ts @@ -0,0 +1,20 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchMilestonesHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/milestones`, + undefined, + data => ({ milestones: (data as Record)?.insights || [] }), + ); +} diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts new file mode 100644 index 00000000..03a32e14 --- /dev/null +++ b/lib/research/getResearchPlaylistHandler.ts @@ -0,0 +1,91 @@ +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"; + +/** + * Playlist detail handler — looks up a playlist by platform and ID, falling back to name search. + * + * @param request - query params: platform, id + * @returns JSON playlist details or error + */ +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() }, + ); + } + + const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + if (!VALID_PLATFORMS.includes(platform)) { + return NextResponse.json( + { status: "error", error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}` }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + let playlistId = id; + + if (!/^\d+$/.test(id)) { + const searchResult = await proxyToChartmetric("/search", { + q: id, + type: "playlists", + limit: "1", + }); + + if (searchResult.status !== 200) { + return NextResponse.json( + { status: "error", error: `Search failed with status ${searchResult.status}` }, + { status: searchResult.status, headers: getCorsHeaders() }, + ); + } + + 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( + { status: "error", error: "Playlist lookup failed" }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + 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..04b44185 --- /dev/null +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -0,0 +1,61 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * Playlists handler — returns playlists featuring an artist. Supports `?platform=`, `?status=`, `?limit=`, `?sort=`, `?since=`, and playlist-type filters. + * + * @param request - must include `artist` query param + * @returns JSON playlist placements or error + */ +export async function getResearchPlaylistsHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform") || "spotify"; + const status = searchParams.get("status") || "current"; + + const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; + if (!VALID_PLATFORMS.includes(platform)) { + return NextResponse.json( + { status: "error", error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}` }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + 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 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 { + params.editorial = "true"; + params.indie = "true"; + params.majorCurator = "true"; + params.popularIndie = "true"; + } + + return params; + }, + data => ({ placements: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts new file mode 100644 index 00000000..f09d1c53 --- /dev/null +++ b/lib/research/getResearchProfileHandler.ts @@ -0,0 +1,15 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchProfileHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}`); +} diff --git a/lib/research/getResearchRadioHandler.ts b/lib/research/getResearchRadioHandler.ts new file mode 100644 index 00000000..246c51c4 --- /dev/null +++ b/lib/research/getResearchRadioHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/radio + * + * Returns a list of radio stations. + * + * @param request - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchRadioHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/radio/station-list", + undefined, + data => ({ stations: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts new file mode 100644 index 00000000..4a68f6be --- /dev/null +++ b/lib/research/getResearchRankHandler.ts @@ -0,0 +1,19 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchRankHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/artist-rank`, + undefined, + data => ({ rank: (data as Record)?.artist_rank || null }), + ); +} diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts new file mode 100644 index 00000000..d7e9c59e --- /dev/null +++ b/lib/research/getResearchSearchHandler.ts @@ -0,0 +1,52 @@ +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"; + +/** + * Search handler — looks up artists/tracks/albums by name via Chartmetric. + * + * @param request - must include `q` query param + * @returns JSON search results or error + */ +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() }, + ); + } + + 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() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + 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..a50db87c --- /dev/null +++ b/lib/research/getResearchSimilarHandler.ts @@ -0,0 +1,36 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchSimilarHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/similar-artists/by-configurations`, + sp => { + const params: Record = {}; + for (const key of CONFIG_PARAMS) { + const val = sp.get(key); + params[key] = val || "medium"; + } + const limit = sp.get("limit"); + 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/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts new file mode 100644 index 00000000..da0a196c --- /dev/null +++ b/lib/research/getResearchTrackHandler.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 { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * Track handler — searches Chartmetric for a track by name, then fetches full details for the top match. + * + * @param request - must include `q` query param + * @returns JSON track details or error + */ +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() }, + ); + } + + 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() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + 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..8664017a --- /dev/null +++ b/lib/research/getResearchTracksHandler.ts @@ -0,0 +1,20 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchTracksHandler(request: NextRequest) { + 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 new file mode 100644 index 00000000..d947e462 --- /dev/null +++ b/lib/research/getResearchUrlsHandler.ts @@ -0,0 +1,28 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchUrlsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/urls`, + undefined, + data => ({ + urls: Array.isArray(data) + ? data + : Object.entries(data as Record).map(([domain, url]) => ({ + domain, + url, + })), + }), + ); +} diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts new file mode 100644 index 00000000..be9e1077 --- /dev/null +++ b/lib/research/getResearchVenuesHandler.ts @@ -0,0 +1,19 @@ +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 - The incoming HTTP request. + * @returns The JSON response. + */ +export async function getResearchVenuesHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/venues`, + undefined, + data => ({ venues: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts new file mode 100644 index 00000000..630357dc --- /dev/null +++ b/lib/research/handleArtistResearch.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 { 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 - must include `artist` query param for Chartmetric resolution + * @param buildPath - maps resolved Chartmetric ID to API path + * @param getQueryParams - extracts additional query params from the request + * @param transformResponse - reshapes the proxy response data + * @returns JSON response with artist data or error + */ +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() }, + ); + } + + 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() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + 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() }, + ); +} diff --git a/lib/research/handleResearchRequest.ts b/lib/research/handleResearchRequest.ts new file mode 100644 index 00000000..fd4384f2 --- /dev/null +++ b/lib/research/handleResearchRequest.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"; + +/** + * Shared handler for non-artist-scoped research endpoints. Handles auth, credit deduction, and proxying to Chartmetric. + * + * @param request - incoming HTTP request + * @param buildPath - returns the Chartmetric API path + * @param getQueryParams - extracts query params from the request + * @param transformResponse - reshapes the proxy response data + * @param credits - credits to deduct (default 5) + * @returns JSON response with data or error + */ +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; + + 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() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: credits }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + 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() }, + ); +} diff --git a/lib/research/postResearchDeepHandler.ts b/lib/research/postResearchDeepHandler.ts new file mode 100644 index 00000000..a213fbc5 --- /dev/null +++ b/lib/research/postResearchDeepHandler.ts @@ -0,0 +1,63 @@ +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 { chatWithPerplexity } from "@/lib/perplexity/chatWithPerplexity"; + +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), +}); + +/** + * Deep research handler — performs comprehensive research via Perplexity sonar-deep-research with citations. + * + * @param request - JSON body with `query` string + * @returns JSON research report with citations or error + */ +export async function postResearchDeepHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + 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: message ?? "Invalid request body" }, + { status: 400, 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", + 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/postResearchEnrichHandler.ts b/lib/research/postResearchEnrichHandler.ts new file mode 100644 index 00000000..9cd98493 --- /dev/null +++ b/lib/research/postResearchEnrichHandler.ts @@ -0,0 +1,79 @@ +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 + * + * 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? } + * @returns JSON success or error response + */ +export async function postResearchEnrichHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + 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: message ?? "Invalid request body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const creditCost = body.processor === "ultra" ? 25 : body.processor === "core" ? 10 : 5; + + try { + const result = await enrichEntity(body.input, body.schema, body.processor); + + 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() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: creditCost }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + return NextResponse.json( + { + status: "success", + output: result.output, + citations: result.citations, + }, + { 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..525d5142 --- /dev/null +++ b/lib/research/postResearchExtractHandler.ts @@ -0,0 +1,65 @@ +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 + * + * Extract clean markdown content from one or more URLs. + * 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 { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + 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: message ?? "Invalid request body" }, + { status: 400, 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", + 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() }, + ); + } +} diff --git a/lib/research/postResearchPeopleHandler.ts b/lib/research/postResearchPeopleHandler.ts new file mode 100644 index 00000000..0cc1c2d6 --- /dev/null +++ b/lib/research/postResearchPeopleHandler.ts @@ -0,0 +1,64 @@ +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 + * + * 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? } + * @returns JSON success or error response + */ +export async function postResearchPeopleHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + 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: message ?? "Invalid request body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + 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( + { + 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..5b69533f --- /dev/null +++ b/lib/research/postResearchWebHandler.ts @@ -0,0 +1,70 @@ +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(), +}); + +/** + * Web search handler — queries Perplexity for real-time web results with formatted markdown output. + * + * @param request - JSON body with `query`, optional `max_results` and `country` + * @returns JSON search results with formatted markdown or error + */ +export async function postResearchWebHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + let body: z.infer; + try { + 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: message ?? "Invalid request body" }, + { status: 400, 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); + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — log but don't block + } + + 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() }, + ); + } +} diff --git a/lib/research/proxyToChartmetric.ts b/lib/research/proxyToChartmetric.ts new file mode 100644 index 00000000..aee4f8b5 --- /dev/null +++ b/lib/research/proxyToChartmetric.ts @@ -0,0 +1,57 @@ +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 { + try { + 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 }; + } catch { + return { data: null, status: 500 }; + } +} 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 }; +} diff --git a/lib/research/validateDiscoverQuery.ts b/lib/research/validateDiscoverQuery.ts new file mode 100644 index 00000000..f0a54883 --- /dev/null +++ b/lib/research/validateDiscoverQuery.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const discoverQuerySchema = z.object({ + country: z.string().length(2, "country must be a 2-letter ISO code").optional(), + genre: z.string().optional(), + sort: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + sp_monthly_listeners_min: z.coerce.number().int().min(0).optional(), + sp_monthly_listeners_max: z.coerce.number().int().min(0).optional(), +}); + +export type DiscoverQuery = z.infer; + +/** + * Validates query params for GET /api/research/discover. + */ +export function validateDiscoverQuery(searchParams: URLSearchParams): NextResponse | DiscoverQuery { + const raw: Record = {}; + for (const key of [ + "country", + "genre", + "sort", + "limit", + "sp_monthly_listeners_min", + "sp_monthly_listeners_max", + ]) { + const val = searchParams.get(key); + if (val) raw[key] = val; + } + + const result = discoverQuerySchema.safeParse(raw); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} From 9b9f60a5e53b94750f9bb04d5c41b648d206f8df Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:51:31 -0400 Subject: [PATCH 2/7] feat: add GET /api/research/track/playlists endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track-level playlist lookup — returns editorial, indie, and algorithmic playlists for a specific track. Accepts Chartmetric track ID or track name (resolved via search). Proxies to Chartmetric /track/{id}/{platform}/{status}/playlists. Includes route handler, domain handler, MCP tool, and 8 unit tests. Made-with: Cursor --- app/api/research/track/playlists/route.ts | 22 +++ lib/mcp/tools/research/index.ts | 2 + .../registerResearchTrackPlaylistsTool.ts | 137 +++++++++++++ .../getResearchTrackPlaylistsHandler.test.ts | 181 ++++++++++++++++++ .../getResearchTrackPlaylistsHandler.ts | 146 ++++++++++++++ 5 files changed, 488 insertions(+) create mode 100644 app/api/research/track/playlists/route.ts create mode 100644 lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts create mode 100644 lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts create mode 100644 lib/research/getResearchTrackPlaylistsHandler.ts diff --git a/app/api/research/track/playlists/route.ts b/app/api/research/track/playlists/route.ts new file mode 100644 index 00000000..0eb5c13f --- /dev/null +++ b/app/api/research/track/playlists/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTrackPlaylistsHandler } from "@/lib/research/getResearchTrackPlaylistsHandler"; + +/** + * OPTIONS /api/research/track/playlists — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/track/playlists — Playlists featuring a specific track. Requires `?id=` or `?q=` query param. + * + * @param request - must include `id` (Chartmetric track ID) or `q` (track name) query param + * @returns JSON playlist placements for the track or error + */ +export async function GET(request: NextRequest) { + return getResearchTrackPlaylistsHandler(request); +} diff --git a/lib/mcp/tools/research/index.ts b/lib/mcp/tools/research/index.ts index 423a767a..9830107b 100644 --- a/lib/mcp/tools/research/index.ts +++ b/lib/mcp/tools/research/index.ts @@ -27,6 +27,7 @@ import { registerResearchRankTool } from "./registerResearchRankTool"; import { registerResearchChartsTool } from "./registerResearchChartsTool"; import { registerResearchRadioTool } from "./registerResearchRadioTool"; import { registerResearchSearchTool } from "./registerResearchSearchTool"; +import { registerResearchTrackPlaylistsTool } from "./registerResearchTrackPlaylistsTool"; /** * Registers all research-related MCP tools on the server. * @@ -61,4 +62,5 @@ export const registerAllResearchTools = (server: McpServer): void => { registerResearchChartsTool(server); registerResearchRadioTool(server); registerResearchSearchTool(server); + registerResearchTrackPlaylistsTool(server); }; diff --git a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts new file mode 100644 index 00000000..762321a5 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts @@ -0,0 +1,137 @@ +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"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { deductCredits } from "@/lib/credits/deductCredits"; + +const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; + +const schema = z.object({ + id: z + .string() + .optional() + .describe("Chartmetric track ID. Provide this or q."), + q: z + .string() + .optional() + .describe("Track name to search for. Provide this or id."), + 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(10) + .describe("Maximum number of playlists to return (default: 10)"), +}); + +/** + * Registers the "get_track_playlists" tool on the MCP server. + * Returns playlist placements for a specific track. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchTrackPlaylistsTool(server: McpServer): void { + server.registerTool( + "get_track_playlists", + { + description: + "Get playlists featuring a specific track. " + + "Use this to find which editorial, indie, and algorithmic playlists a particular song is on. " + + "Returns playlist name, cover image, follower count, and curator.", + inputSchema: schema, + }, + async (args, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + if (error) return getToolResultError(error); + if (!accountId) return getToolResultError("Failed to resolve account ID"); + + if (!args.id && !args.q) { + return getToolResultError("Either id or q parameter is required"); + } + + try { + let trackId = args.id; + + if (!trackId) { + const searchResult = await proxyToChartmetric("/search", { + q: args.q!, + type: "tracks", + limit: "1", + }); + + if (searchResult.status !== 200) { + return getToolResultError(`Track search failed with status ${searchResult.status}`); + } + + const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; + if (!tracks || tracks.length === 0) { + return getToolResultError(`No track found matching "${args.q}"`); + } + + trackId = String(tracks[0].id); + } + + 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 = {}; + 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( + `/track/${trackId}/${platform}/${status}/playlists`, + queryParams, + ); + + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + + return getToolResultSuccess({ + placements: Array.isArray(result.data) ? result.data : [], + }); + } catch (err) { + return getToolResultError( + err instanceof Error ? err.message : "Failed to fetch track playlists", + ); + } + }, + ); +} diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts new file mode 100644 index 00000000..40a5138b --- /dev/null +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getResearchTrackPlaylistsHandler } from "../getResearchTrackPlaylistsHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/proxyToChartmetric", () => ({ + proxyToChartmetric: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +describe("getResearchTrackPlaylistsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); + + const req = new NextRequest("http://localhost/api/research/track/playlists?id=18220712"); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when both id and q are missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest("http://localhost/api/research/track/playlists"); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("id or q parameter is required"); + }); + + it("returns 400 for invalid platform", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?id=123&platform=invalid", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("Invalid platform"); + }); + + it("returns 400 for invalid status", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?id=123&status=invalid", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("status must be"); + }); + + it("returns 200 with playlists when given a track id", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [ + { + playlist: { name: "Chill Vibes", image_url: "https://i.scdn.co/image/abc", editorial: true }, + track: { name: "God's Plan", cm_track: 18220712 }, + }, + ], + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?id=18220712&editorial=true", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.placements).toHaveLength(1); + expect(body.placements[0].playlist.name).toBe("Chill Vibes"); + }); + + it("resolves track by name when q is provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric) + .mockResolvedValueOnce({ + data: { tracks: [{ id: 18220712 }] }, + status: 200, + }) + .mockResolvedValueOnce({ + data: [ + { + playlist: { name: "Today's Top Hits", image_url: "https://i.scdn.co/image/xyz" }, + track: { name: "God's Plan" }, + }, + ], + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?q=God%27s+Plan+Drake", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("success"); + expect(body.placements).toHaveLength(1); + }); + + it("returns 404 when track name search finds nothing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: { tracks: [] }, + status: 200, + }); + + const req = new NextRequest( + "http://localhost/api/research/track/playlists?q=nonexistent+song", + ); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(404); + }); + + it("returns empty placements when Chartmetric returns non-array", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "test-id", + orgId: null, + authToken: "token", + }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: null, + status: 200, + }); + + const req = new NextRequest("http://localhost/api/research/track/playlists?id=123"); + const res = await getResearchTrackPlaylistsHandler(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.placements).toEqual([]); + }); +}); diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts new file mode 100644 index 00000000..b6456b3b --- /dev/null +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -0,0 +1,146 @@ +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 VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; + +/** + * Track playlists handler — returns playlists featuring a specific track. + * Accepts a Chartmetric track ID or a track name (resolved via search). + * + * @param request - query params: id or q, platform, status, editorial, limit + * @returns JSON playlist placements for the track or error + */ +export async function getResearchTrackPlaylistsHandler( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const q = searchParams.get("q"); + + if (!id && !q) { + return NextResponse.json( + { status: "error", error: "id or q parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const platform = searchParams.get("platform") || "spotify"; + if (!VALID_PLATFORMS.includes(platform)) { + return NextResponse.json( + { + status: "error", + error: `Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const status = searchParams.get("status") || "current"; + if (status !== "current" && status !== "past") { + return NextResponse.json( + { status: "error", error: "status must be 'current' or 'past'" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + let trackId = id; + + if (!trackId) { + const searchResult = await proxyToChartmetric("/search", { + q: q!, + type: "tracks", + limit: "1", + }); + + if (searchResult.status !== 200) { + return NextResponse.json( + { status: "error", error: `Track search failed with status ${searchResult.status}` }, + { status: searchResult.status, headers: getCorsHeaders() }, + ); + } + + const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; + if (!tracks || tracks.length === 0) { + return NextResponse.json( + { status: "error", error: `No track found matching "${q}"` }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + trackId = String(tracks[0].id); + } + + const queryParams: Record = {}; + const limit = searchParams.get("limit"); + if (limit) queryParams.limit = limit; + const offset = searchParams.get("offset"); + if (offset) queryParams.offset = offset; + const since = searchParams.get("since"); + if (since) queryParams.since = since; + const until = searchParams.get("until"); + if (until) queryParams.until = until; + const sortColumn = searchParams.get("sort"); + if (sortColumn) queryParams.sortColumn = sortColumn; + + const filterParams = [ + "editorial", + "indie", + "majorCurator", + "popularIndie", + "personalized", + "chart", + "newMusicFriday", + "thisIs", + "radio", + "brand", + ]; + + let hasFilters = false; + for (const param of filterParams) { + const value = searchParams.get(param); + if (value !== null) { + queryParams[param] = value; + hasFilters = true; + } + } + + if (!hasFilters) { + queryParams.editorial = "true"; + queryParams.indie = "true"; + queryParams.majorCurator = "true"; + queryParams.popularIndie = "true"; + } + + const result = await proxyToChartmetric( + `/track/${trackId}/${platform}/${status}/playlists`, + queryParams, + ); + + if (result.status !== 200) { + return NextResponse.json( + { status: "error", error: `Request failed with status ${result.status}` }, + { status: result.status, headers: getCorsHeaders() }, + ); + } + + try { + await deductCredits({ accountId, creditsToDeduct: 5 }); + } catch { + // Credit deduction failed but data was fetched — don't block + } + + return NextResponse.json( + { + status: "success", + placements: Array.isArray(result.data) ? result.data : [], + }, + { status: 200, headers: getCorsHeaders() }, + ); +} From ed94ece3f0ffdba2a591d2c63da5d213cc2ed898 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:57:39 -0400 Subject: [PATCH 3/7] fix: use Spotify-powered track search for reliable q= resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds resolveTrack() — searches Spotify first (accurate matching with artist: filter), maps Spotify track ID to Chartmetric ID, falls back to Chartmetric search if Spotify fails. Adds optional artist= param to track/playlists endpoint and MCP tool. Made-with: Cursor --- .../registerResearchTrackPlaylistsTool.ts | 24 +++--- .../getResearchTrackPlaylistsHandler.test.ts | 38 ++++----- .../getResearchTrackPlaylistsHandler.ts | 28 ++----- lib/research/resolveTrack.ts | 80 +++++++++++++++++++ 4 files changed, 116 insertions(+), 54 deletions(-) create mode 100644 lib/research/resolveTrack.ts diff --git a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts index 762321a5..2e665265 100644 --- a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts +++ b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { resolveTrack } from "@/lib/research/resolveTrack"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; @@ -20,6 +21,10 @@ const schema = z.object({ .string() .optional() .describe("Track name to search for. Provide this or id."), + artist: z + .string() + .optional() + .describe("Artist name — improves track search accuracy when using q."), platform: z .string() .optional() @@ -71,22 +76,11 @@ export function registerResearchTrackPlaylistsTool(server: McpServer): void { let trackId = args.id; if (!trackId) { - const searchResult = await proxyToChartmetric("/search", { - q: args.q!, - type: "tracks", - limit: "1", - }); - - if (searchResult.status !== 200) { - return getToolResultError(`Track search failed with status ${searchResult.status}`); + const resolved = await resolveTrack(args.q!, args.artist); + if (resolved.error) { + return getToolResultError(resolved.error); } - - const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; - if (!tracks || tracks.length === 0) { - return getToolResultError(`No track found matching "${args.q}"`); - } - - trackId = String(tracks[0].id); + trackId = resolved.id; } const platform = args.platform ?? "spotify"; diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 40a5138b..5789e451 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchTrackPlaylistsHandler } from "../getResearchTrackPlaylistsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; +import { resolveTrack } from "@/lib/research/resolveTrack"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -17,6 +18,10 @@ vi.mock("@/lib/research/proxyToChartmetric", () => ({ proxyToChartmetric: vi.fn(), })); +vi.mock("@/lib/research/resolveTrack", () => ({ + resolveTrack: vi.fn(), +})); + vi.mock("@/lib/credits/deductCredits", () => ({ deductCredits: vi.fn(), })); @@ -116,29 +121,27 @@ describe("getResearchTrackPlaylistsHandler", () => { authToken: "token", }); - vi.mocked(proxyToChartmetric) - .mockResolvedValueOnce({ - data: { tracks: [{ id: 18220712 }] }, - status: 200, - }) - .mockResolvedValueOnce({ - data: [ - { - playlist: { name: "Today's Top Hits", image_url: "https://i.scdn.co/image/xyz" }, - track: { name: "God's Plan" }, - }, - ], - status: 200, - }); + vi.mocked(resolveTrack).mockResolvedValue({ id: "18220712" }); + + vi.mocked(proxyToChartmetric).mockResolvedValue({ + data: [ + { + playlist: { name: "Today's Top Hits", image_url: "https://i.scdn.co/image/xyz" }, + track: { name: "God's Plan" }, + }, + ], + status: 200, + }); const req = new NextRequest( - "http://localhost/api/research/track/playlists?q=God%27s+Plan+Drake", + "http://localhost/api/research/track/playlists?q=God%27s+Plan&artist=Drake", ); const res = await getResearchTrackPlaylistsHandler(req); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe("success"); expect(body.placements).toHaveLength(1); + expect(vi.mocked(resolveTrack)).toHaveBeenCalledWith("God's Plan", "Drake"); }); it("returns 404 when track name search finds nothing", async () => { @@ -148,10 +151,7 @@ describe("getResearchTrackPlaylistsHandler", () => { authToken: "token", }); - vi.mocked(proxyToChartmetric).mockResolvedValue({ - data: { tracks: [] }, - status: 200, - }); + vi.mocked(resolveTrack).mockResolvedValue({ error: "No track found matching \"nonexistent song\"" }); const req = new NextRequest( "http://localhost/api/research/track/playlists?q=nonexistent+song", diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts index b6456b3b..bf201a95 100644 --- a/lib/research/getResearchTrackPlaylistsHandler.ts +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -3,14 +3,15 @@ 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 { resolveTrack } from "@/lib/research/resolveTrack"; const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; /** * Track playlists handler — returns playlists featuring a specific track. - * Accepts a Chartmetric track ID or a track name (resolved via search). + * Accepts a Chartmetric track ID, or a track name + optional artist for Spotify-powered lookup. * - * @param request - query params: id or q, platform, status, editorial, limit + * @param request - query params: id or q (+artist), platform, status, editorial, limit * @returns JSON playlist placements for the track or error */ export async function getResearchTrackPlaylistsHandler( @@ -23,6 +24,7 @@ export async function getResearchTrackPlaylistsHandler( const { searchParams } = new URL(request.url); const id = searchParams.get("id"); const q = searchParams.get("q"); + const artist = searchParams.get("artist") || undefined; if (!id && !q) { return NextResponse.json( @@ -53,28 +55,14 @@ export async function getResearchTrackPlaylistsHandler( let trackId = id; if (!trackId) { - const searchResult = await proxyToChartmetric("/search", { - q: q!, - type: "tracks", - limit: "1", - }); - - if (searchResult.status !== 200) { - return NextResponse.json( - { status: "error", error: `Track search failed with status ${searchResult.status}` }, - { status: searchResult.status, headers: getCorsHeaders() }, - ); - } - - const tracks = (searchResult.data as { tracks?: Array<{ id: number }> })?.tracks; - if (!tracks || tracks.length === 0) { + const resolved = await resolveTrack(q!, artist); + if (resolved.error) { return NextResponse.json( - { status: "error", error: `No track found matching "${q}"` }, + { status: "error", error: resolved.error }, { status: 404, headers: getCorsHeaders() }, ); } - - trackId = String(tracks[0].id); + trackId = resolved.id; } const queryParams: Record = {}; diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts new file mode 100644 index 00000000..f905b7de --- /dev/null +++ b/lib/research/resolveTrack.ts @@ -0,0 +1,80 @@ +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getSearch from "@/lib/spotify/getSearch"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +interface SpotifyTrackItem { + id: string; + name: string; + artists: Array<{ name: string }>; + external_ids?: { isrc?: string }; +} + +/** + * Resolves a track name (+ optional artist) to a Chartmetric track ID. + * + * Uses Spotify search for accurate matching, then maps the Spotify track ID + * to a Chartmetric ID via their /track/spotify/{id} endpoint. + * + * Falls back to Chartmetric's own search if Spotify lookup fails. + */ +export async function resolveTrack( + q: string, + artist?: string, +): Promise<{ id: string; error?: never } | { id?: never; error: string }> { + const searchQuery = artist ? `${q} artist:${artist}` : q; + + const tokenResult = await generateAccessToken(); + if (tokenResult.error || !tokenResult.access_token) { + return fallbackChartmetricSearch(q); + } + + const { data, error } = await getSearch({ + q: searchQuery, + type: "track", + limit: 1, + accessToken: tokenResult.access_token, + }); + + if (error || !data) { + return fallbackChartmetricSearch(q); + } + + const tracks: SpotifyTrackItem[] = data?.tracks?.items ?? []; + if (tracks.length === 0) { + return { error: `No track found matching "${q}"${artist ? ` by ${artist}` : ""}` }; + } + + const spotifyTrackId = tracks[0].id; + + const cmResult = await proxyToChartmetric(`/track/spotify/${spotifyTrackId}`); + if (cmResult.status === 200 && cmResult.data) { + const cmData = cmResult.data as { id?: number } | Array<{ id?: number }>; + const cmId = Array.isArray(cmData) ? cmData[0]?.id : cmData.id; + if (cmId) { + return { id: String(cmId) }; + } + } + + return fallbackChartmetricSearch(q); +} + +async function fallbackChartmetricSearch( + q: string, +): Promise<{ id: string; error?: never } | { id?: never; error: string }> { + const result = await proxyToChartmetric("/search", { + q, + type: "tracks", + limit: "1", + }); + + if (result.status !== 200) { + return { error: `Track search failed with status ${result.status}` }; + } + + const tracks = (result.data as { tracks?: Array<{ id: number }> })?.tracks; + if (!tracks || tracks.length === 0) { + return { error: `No track found matching "${q}"` }; + } + + return { id: String(tracks[0].id) }; +} From c62fc774bd8717dc5c14a2365e4a205eb5b4ee3f Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:01:00 -0400 Subject: [PATCH 4/7] fix: resolve tracks via ISRC for reliable Chartmetric ID mapping Spotify search returns ISRC, which maps to Chartmetric more reliably than Spotify track ID. Tries /track/isrc/{isrc} first, then /track/spotify/{id}, then falls back to Chartmetric text search. Made-with: Cursor --- lib/research/resolveTrack.ts | 53 ++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index f905b7de..1d72366d 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -12,10 +12,10 @@ interface SpotifyTrackItem { /** * Resolves a track name (+ optional artist) to a Chartmetric track ID. * - * Uses Spotify search for accurate matching, then maps the Spotify track ID - * to a Chartmetric ID via their /track/spotify/{id} endpoint. + * Uses Spotify search for accurate matching, then maps the ISRC + * to a Chartmetric ID via their /track/isrc/{isrc} endpoint. * - * Falls back to Chartmetric's own search if Spotify lookup fails. + * Falls back to Chartmetric's own search if Spotify/ISRC lookup fails. */ export async function resolveTrack( q: string, @@ -44,20 +44,51 @@ export async function resolveTrack( return { error: `No track found matching "${q}"${artist ? ` by ${artist}` : ""}` }; } - const spotifyTrackId = tracks[0].id; + const spotifyTrack = tracks[0]; + const isrc = spotifyTrack.external_ids?.isrc; - const cmResult = await proxyToChartmetric(`/track/spotify/${spotifyTrackId}`); - if (cmResult.status === 200 && cmResult.data) { - const cmData = cmResult.data as { id?: number } | Array<{ id?: number }>; - const cmId = Array.isArray(cmData) ? cmData[0]?.id : cmData.id; - if (cmId) { - return { id: String(cmId) }; - } + if (isrc) { + const cmId = await chartmetricIdFromIsrc(isrc); + if (cmId) return { id: cmId }; } + const cmId = await chartmetricIdFromSpotify(spotifyTrack.id); + if (cmId) return { id: cmId }; + return fallbackChartmetricSearch(q); } +/** Extract a Chartmetric track ID from any response shape. */ +function extractCmTrackId(data: unknown): string | null { + if (!data) return null; + + if (Array.isArray(data) && data.length > 0) { + const first = data[0] as Record; + const id = first.cm_track ?? first.id; + if (id != null) return String(id); + } + + if (typeof data === "object" && data !== null) { + const obj = data as Record; + const id = obj.cm_track ?? obj.id; + if (id != null) return String(id); + } + + return null; +} + +async function chartmetricIdFromIsrc(isrc: string): Promise { + const result = await proxyToChartmetric(`/track/isrc/${isrc}`); + if (result.status !== 200) return null; + return extractCmTrackId(result.data); +} + +async function chartmetricIdFromSpotify(spotifyId: string): Promise { + const result = await proxyToChartmetric(`/track/spotify/${spotifyId}`); + if (result.status !== 200) return null; + return extractCmTrackId(result.data); +} + async function fallbackChartmetricSearch( q: string, ): Promise<{ id: string; error?: never } | { id?: never; error: string }> { From e65ef6affca00dea1a1c0c775403ac3f8ceac05c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:04:02 -0400 Subject: [PATCH 5/7] fix: resolve track ID via artist playlists + tracks matching Spotify search finds exact track name, then we match against the artist's Chartmetric playlists/tracks by name to get the cm_track ID. Avoids Chartmetric's broken text search and unreliable ID mapping. Made-with: Cursor --- lib/research/resolveTrack.ts | 111 ++++++++++++++--------------------- 1 file changed, 44 insertions(+), 67 deletions(-) diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index 1d72366d..967c59d1 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -1,21 +1,14 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getSearch from "@/lib/spotify/getSearch"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; - -interface SpotifyTrackItem { - id: string; - name: string; - artists: Array<{ name: string }>; - external_ids?: { isrc?: string }; -} +import { resolveArtist } from "@/lib/research/resolveArtist"; /** * Resolves a track name (+ optional artist) to a Chartmetric track ID. * - * Uses Spotify search for accurate matching, then maps the ISRC - * to a Chartmetric ID via their /track/isrc/{isrc} endpoint. - * - * Falls back to Chartmetric's own search if Spotify/ISRC lookup fails. + * Strategy: Spotify search finds the exact track name, then we look through + * the artist's Chartmetric playlists to find the matching cm_track ID. + * This avoids Chartmetric's unreliable text search entirely. */ export async function resolveTrack( q: string, @@ -25,7 +18,7 @@ export async function resolveTrack( const tokenResult = await generateAccessToken(); if (tokenResult.error || !tokenResult.access_token) { - return fallbackChartmetricSearch(q); + return { error: "Failed to authenticate with Spotify" }; } const { data, error } = await getSearch({ @@ -36,7 +29,13 @@ export async function resolveTrack( }); if (error || !data) { - return fallbackChartmetricSearch(q); + return { error: "Spotify search failed" }; + } + + interface SpotifyTrackItem { + id: string; + name: string; + artists: Array<{ name: string }>; } const tracks: SpotifyTrackItem[] = data?.tracks?.items ?? []; @@ -45,67 +44,45 @@ export async function resolveTrack( } const spotifyTrack = tracks[0]; - const isrc = spotifyTrack.external_ids?.isrc; - - if (isrc) { - const cmId = await chartmetricIdFromIsrc(isrc); - if (cmId) return { id: cmId }; - } - - const cmId = await chartmetricIdFromSpotify(spotifyTrack.id); - if (cmId) return { id: cmId }; - - return fallbackChartmetricSearch(q); -} - -/** Extract a Chartmetric track ID from any response shape. */ -function extractCmTrackId(data: unknown): string | null { - if (!data) return null; + const exactName = spotifyTrack.name; + const artistName = artist || spotifyTrack.artists?.[0]?.name; - if (Array.isArray(data) && data.length > 0) { - const first = data[0] as Record; - const id = first.cm_track ?? first.id; - if (id != null) return String(id); + if (!artistName) { + return { error: `Found track "${exactName}" but could not determine artist` }; } - if (typeof data === "object" && data !== null) { - const obj = data as Record; - const id = obj.cm_track ?? obj.id; - if (id != null) return String(id); + const resolved = await resolveArtist(artistName); + if (resolved.error) { + return { error: `Artist lookup failed: ${resolved.error}` }; } - return null; -} - -async function chartmetricIdFromIsrc(isrc: string): Promise { - const result = await proxyToChartmetric(`/track/isrc/${isrc}`); - if (result.status !== 200) return null; - return extractCmTrackId(result.data); -} - -async function chartmetricIdFromSpotify(spotifyId: string): Promise { - const result = await proxyToChartmetric(`/track/spotify/${spotifyId}`); - if (result.status !== 200) return null; - return extractCmTrackId(result.data); -} - -async function fallbackChartmetricSearch( - q: string, -): Promise<{ id: string; error?: never } | { id?: never; error: string }> { - const result = await proxyToChartmetric("/search", { - q, - type: "tracks", - limit: "1", - }); - - if (result.status !== 200) { - return { error: `Track search failed with status ${result.status}` }; + const playlistsResult = await proxyToChartmetric( + `/artist/${resolved.id}/spotify/current/playlists`, + { editorial: "true", indie: "true", majorCurator: "true", popularIndie: "true", limit: "100" }, + ); + + if (playlistsResult.status === 200 && Array.isArray(playlistsResult.data)) { + const normalizedTarget = exactName.toLowerCase(); + for (const placement of playlistsResult.data as Array>) { + const trackName = String(placement.track ?? "").toLowerCase(); + const cmTrack = placement.cm_track; + if (cmTrack && trackName.includes(normalizedTarget)) { + return { id: String(cmTrack) }; + } + } } - const tracks = (result.data as { tracks?: Array<{ id: number }> })?.tracks; - if (!tracks || tracks.length === 0) { - return { error: `No track found matching "${q}"` }; + const tracksResult = await proxyToChartmetric(`/artist/${resolved.id}/tracks`); + if (tracksResult.status === 200 && Array.isArray(tracksResult.data)) { + const normalizedTarget = exactName.toLowerCase(); + for (const track of tracksResult.data as Array>) { + const trackName = String(track.name ?? "").toLowerCase(); + const cmTrack = track.cm_track ?? track.id; + if (cmTrack && trackName.includes(normalizedTarget)) { + return { id: String(cmTrack) }; + } + } } - return { id: String(tracks[0].id) }; + return { error: `Could not find Chartmetric ID for "${exactName}" by ${artistName}` }; } From 46d970135b4d9a3d79e42a05d6343dce8a00d72f Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:10:44 -0400 Subject: [PATCH 6/7] fix: use Chartmetric /track/:type/:id/get-ids for track resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps ISRC → chartmetric_ids via the correct endpoint path. Falls back to Spotify track ID if ISRC lookup fails. Platform-agnostic. Made-with: Cursor --- lib/research/resolveTrack.ts | 62 +++++++++++++----------------------- 1 file changed, 22 insertions(+), 40 deletions(-) diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index 967c59d1..ce41526f 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -1,14 +1,17 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getSearch from "@/lib/spotify/getSearch"; import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; -import { resolveArtist } from "@/lib/research/resolveArtist"; + +interface GetIdsResponse { + chartmetric_ids?: number[]; +} /** * Resolves a track name (+ optional artist) to a Chartmetric track ID. * - * Strategy: Spotify search finds the exact track name, then we look through - * the artist's Chartmetric playlists to find the matching cm_track ID. - * This avoids Chartmetric's unreliable text search entirely. + * Uses Spotify search for accurate matching, gets the ISRC, then maps + * to a Chartmetric ID via /track/isrc/{isrc}/get-ids. + * Works across all platforms since ISRC is a universal identifier. */ export async function resolveTrack( q: string, @@ -35,7 +38,7 @@ export async function resolveTrack( interface SpotifyTrackItem { id: string; name: string; - artists: Array<{ name: string }>; + external_ids?: { isrc?: string }; } const tracks: SpotifyTrackItem[] = data?.tracks?.items ?? []; @@ -44,45 +47,24 @@ export async function resolveTrack( } const spotifyTrack = tracks[0]; - const exactName = spotifyTrack.name; - const artistName = artist || spotifyTrack.artists?.[0]?.name; - - if (!artistName) { - return { error: `Found track "${exactName}" but could not determine artist` }; - } + const isrc = spotifyTrack.external_ids?.isrc; - const resolved = await resolveArtist(artistName); - if (resolved.error) { - return { error: `Artist lookup failed: ${resolved.error}` }; - } - - const playlistsResult = await proxyToChartmetric( - `/artist/${resolved.id}/spotify/current/playlists`, - { editorial: "true", indie: "true", majorCurator: "true", popularIndie: "true", limit: "100" }, - ); - - if (playlistsResult.status === 200 && Array.isArray(playlistsResult.data)) { - const normalizedTarget = exactName.toLowerCase(); - for (const placement of playlistsResult.data as Array>) { - const trackName = String(placement.track ?? "").toLowerCase(); - const cmTrack = placement.cm_track; - if (cmTrack && trackName.includes(normalizedTarget)) { - return { id: String(cmTrack) }; - } + if (isrc) { + const result = await proxyToChartmetric(`/track/isrc/${isrc}/get-ids`); + if (result.status === 200) { + const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; + const cmId = ids?.chartmetric_ids?.[0]; + if (cmId) return { id: String(cmId) }; } } - const tracksResult = await proxyToChartmetric(`/artist/${resolved.id}/tracks`); - if (tracksResult.status === 200 && Array.isArray(tracksResult.data)) { - const normalizedTarget = exactName.toLowerCase(); - for (const track of tracksResult.data as Array>) { - const trackName = String(track.name ?? "").toLowerCase(); - const cmTrack = track.cm_track ?? track.id; - if (cmTrack && trackName.includes(normalizedTarget)) { - return { id: String(cmTrack) }; - } - } + const spotifyId = spotifyTrack.id; + const result = await proxyToChartmetric(`/track/spotify/${spotifyId}/get-ids`); + if (result.status === 200) { + const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; + const cmId = ids?.chartmetric_ids?.[0]; + if (cmId) return { id: String(cmId) }; } - return { error: `Could not find Chartmetric ID for "${exactName}" by ${artistName}` }; + return { error: `Could not resolve Chartmetric ID for "${spotifyTrack.name}"` }; } From ae185ef01f5a042f2e8be1294eeb62679ef49cd5 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:02:10 -0400 Subject: [PATCH 7/7] style: fix formatting in research track playlists files Made-with: Cursor --- .../research/registerResearchTrackPlaylistsTool.ts | 10 ++-------- .../getResearchTrackPlaylistsHandler.test.ts | 14 +++++++++----- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts index 2e665265..764e9d5d 100644 --- a/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts +++ b/lib/mcp/tools/research/registerResearchTrackPlaylistsTool.ts @@ -13,14 +13,8 @@ import { deductCredits } from "@/lib/credits/deductCredits"; const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon"]; const schema = z.object({ - id: z - .string() - .optional() - .describe("Chartmetric track ID. Provide this or q."), - q: z - .string() - .optional() - .describe("Track name to search for. Provide this or id."), + id: z.string().optional().describe("Chartmetric track ID. Provide this or q."), + q: z.string().optional().describe("Track name to search for. Provide this or id."), artist: z .string() .optional() diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 5789e451..60df198e 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -96,7 +96,11 @@ describe("getResearchTrackPlaylistsHandler", () => { vi.mocked(proxyToChartmetric).mockResolvedValue({ data: [ { - playlist: { name: "Chill Vibes", image_url: "https://i.scdn.co/image/abc", editorial: true }, + playlist: { + name: "Chill Vibes", + image_url: "https://i.scdn.co/image/abc", + editorial: true, + }, track: { name: "God's Plan", cm_track: 18220712 }, }, ], @@ -151,11 +155,11 @@ describe("getResearchTrackPlaylistsHandler", () => { authToken: "token", }); - vi.mocked(resolveTrack).mockResolvedValue({ error: "No track found matching \"nonexistent song\"" }); + vi.mocked(resolveTrack).mockResolvedValue({ + error: 'No track found matching "nonexistent song"', + }); - const req = new NextRequest( - "http://localhost/api/research/track/playlists?q=nonexistent+song", - ); + const req = new NextRequest("http://localhost/api/research/track/playlists?q=nonexistent+song"); const res = await getResearchTrackPlaylistsHandler(req); expect(res.status).toBe(404); });