diff --git a/app/api/research/albums/route.ts b/app/api/research/albums/route.ts new file mode 100644 index 00000000..d31fd486 --- /dev/null +++ b/app/api/research/albums/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchAlbumsHandler } from "@/lib/research/getResearchAlbumsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchAlbumsHandler(request); +} diff --git a/app/api/research/audience/route.ts b/app/api/research/audience/route.ts new file mode 100644 index 00000000..c1bbc0ab --- /dev/null +++ b/app/api/research/audience/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchAudienceHandler } from "@/lib/research/getResearchAudienceHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchAudienceHandler(request); +} diff --git a/app/api/research/career/route.ts b/app/api/research/career/route.ts new file mode 100644 index 00000000..97ba0a86 --- /dev/null +++ b/app/api/research/career/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCareerHandler } from "@/lib/research/getResearchCareerHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchCareerHandler(request); +} diff --git a/app/api/research/charts/route.ts b/app/api/research/charts/route.ts new file mode 100644 index 00000000..659190fd --- /dev/null +++ b/app/api/research/charts/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchChartsHandler } from "@/lib/research/getResearchChartsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchChartsHandler(request); +} diff --git a/app/api/research/cities/route.ts b/app/api/research/cities/route.ts new file mode 100644 index 00000000..54c0d8fb --- /dev/null +++ b/app/api/research/cities/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCitiesHandler } from "@/lib/research/getResearchCitiesHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchCitiesHandler(request); +} diff --git a/app/api/research/curator/route.ts b/app/api/research/curator/route.ts new file mode 100644 index 00000000..f91980dd --- /dev/null +++ b/app/api/research/curator/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchCuratorHandler } from "@/lib/research/getResearchCuratorHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchCuratorHandler(request); +} diff --git a/app/api/research/deep/route.ts b/app/api/research/deep/route.ts new file mode 100644 index 00000000..a6237dcc --- /dev/null +++ b/app/api/research/deep/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchDeepHandler } from "@/lib/research/postResearchDeepHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/deep + * + * Deep, comprehensive research with citations. + */ +export async function POST(request: NextRequest) { + return postResearchDeepHandler(request); +} diff --git a/app/api/research/discover/route.ts b/app/api/research/discover/route.ts new file mode 100644 index 00000000..044414df --- /dev/null +++ b/app/api/research/discover/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchDiscoverHandler } from "@/lib/research/getResearchDiscoverHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchDiscoverHandler(request); +} diff --git a/app/api/research/enrich/route.ts b/app/api/research/enrich/route.ts new file mode 100644 index 00000000..76b55a91 --- /dev/null +++ b/app/api/research/enrich/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchEnrichHandler } from "@/lib/research/postResearchEnrichHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/enrich + * + * Enrich an entity with structured web research data. + */ +export async function POST(request: NextRequest) { + return postResearchEnrichHandler(request); +} diff --git a/app/api/research/extract/route.ts b/app/api/research/extract/route.ts new file mode 100644 index 00000000..74040d03 --- /dev/null +++ b/app/api/research/extract/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchExtractHandler } from "@/lib/research/postResearchExtractHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/extract + * + * Extract clean markdown from URLs. + */ +export async function POST(request: NextRequest) { + return postResearchExtractHandler(request); +} diff --git a/app/api/research/festivals/route.ts b/app/api/research/festivals/route.ts new file mode 100644 index 00000000..d5a45e45 --- /dev/null +++ b/app/api/research/festivals/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchFestivalsHandler } from "@/lib/research/getResearchFestivalsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchFestivalsHandler(request); +} diff --git a/app/api/research/genres/route.ts b/app/api/research/genres/route.ts new file mode 100644 index 00000000..8db1a0f5 --- /dev/null +++ b/app/api/research/genres/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchGenresHandler } from "@/lib/research/getResearchGenresHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchGenresHandler(request); +} diff --git a/app/api/research/insights/route.ts b/app/api/research/insights/route.ts new file mode 100644 index 00000000..89383290 --- /dev/null +++ b/app/api/research/insights/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchInsightsHandler } from "@/lib/research/getResearchInsightsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchInsightsHandler(request); +} diff --git a/app/api/research/instagram-posts/route.ts b/app/api/research/instagram-posts/route.ts new file mode 100644 index 00000000..983afc7d --- /dev/null +++ b/app/api/research/instagram-posts/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchInstagramPostsHandler } from "@/lib/research/getResearchInstagramPostsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchInstagramPostsHandler(request); +} diff --git a/app/api/research/lookup/route.ts b/app/api/research/lookup/route.ts new file mode 100644 index 00000000..573c4d7e --- /dev/null +++ b/app/api/research/lookup/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchLookupHandler } from "@/lib/research/getResearchLookupHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchLookupHandler(request); +} diff --git a/app/api/research/metrics/route.ts b/app/api/research/metrics/route.ts new file mode 100644 index 00000000..8ca46aee --- /dev/null +++ b/app/api/research/metrics/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMetricsHandler } from "@/lib/research/getResearchMetricsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchMetricsHandler(request); +} diff --git a/app/api/research/milestones/route.ts b/app/api/research/milestones/route.ts new file mode 100644 index 00000000..4e035604 --- /dev/null +++ b/app/api/research/milestones/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMilestonesHandler } from "@/lib/research/getResearchMilestonesHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchMilestonesHandler(request); +} diff --git a/app/api/research/people/route.ts b/app/api/research/people/route.ts new file mode 100644 index 00000000..32d508ca --- /dev/null +++ b/app/api/research/people/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchPeopleHandler } from "@/lib/research/postResearchPeopleHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/people + * + * Search for people in the music industry. + */ +export async function POST(request: NextRequest) { + return postResearchPeopleHandler(request); +} diff --git a/app/api/research/playlist/route.ts b/app/api/research/playlist/route.ts new file mode 100644 index 00000000..95955bab --- /dev/null +++ b/app/api/research/playlist/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchPlaylistHandler } from "@/lib/research/getResearchPlaylistHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchPlaylistHandler(request); +} diff --git a/app/api/research/playlists/route.ts b/app/api/research/playlists/route.ts new file mode 100644 index 00000000..d8d67d3c --- /dev/null +++ b/app/api/research/playlists/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchPlaylistsHandler } from "@/lib/research/getResearchPlaylistsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchPlaylistsHandler(request); +} diff --git a/app/api/research/profile/route.ts b/app/api/research/profile/route.ts new file mode 100644 index 00000000..a21b6417 --- /dev/null +++ b/app/api/research/profile/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchProfileHandler } from "@/lib/research/getResearchProfileHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchProfileHandler(request); +} diff --git a/app/api/research/radio/route.ts b/app/api/research/radio/route.ts new file mode 100644 index 00000000..f3bec7c8 --- /dev/null +++ b/app/api/research/radio/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchRadioHandler } from "@/lib/research/getResearchRadioHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchRadioHandler(request); +} diff --git a/app/api/research/rank/route.ts b/app/api/research/rank/route.ts new file mode 100644 index 00000000..284ddabf --- /dev/null +++ b/app/api/research/rank/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchRankHandler } from "@/lib/research/getResearchRankHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchRankHandler(request); +} diff --git a/app/api/research/route.ts b/app/api/research/route.ts new file mode 100644 index 00000000..b7d7e175 --- /dev/null +++ b/app/api/research/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchSearchHandler } from "@/lib/research/getResearchSearchHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research + * + * Search for artists by name. + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchSearchHandler(request); +} diff --git a/app/api/research/similar/route.ts b/app/api/research/similar/route.ts new file mode 100644 index 00000000..25f0e1ab --- /dev/null +++ b/app/api/research/similar/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchSimilarHandler } from "@/lib/research/getResearchSimilarHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchSimilarHandler(request); +} diff --git a/app/api/research/track/route.ts b/app/api/research/track/route.ts new file mode 100644 index 00000000..a2c86eae --- /dev/null +++ b/app/api/research/track/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTrackHandler } from "@/lib/research/getResearchTrackHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchTrackHandler(request); +} diff --git a/app/api/research/tracks/route.ts b/app/api/research/tracks/route.ts new file mode 100644 index 00000000..adcfc7db --- /dev/null +++ b/app/api/research/tracks/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchTracksHandler } from "@/lib/research/getResearchTracksHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchTracksHandler(request); +} diff --git a/app/api/research/urls/route.ts b/app/api/research/urls/route.ts new file mode 100644 index 00000000..1a74054f --- /dev/null +++ b/app/api/research/urls/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchUrlsHandler } from "@/lib/research/getResearchUrlsHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchUrlsHandler(request); +} diff --git a/app/api/research/venues/route.ts b/app/api/research/venues/route.ts new file mode 100644 index 00000000..a8eb6162 --- /dev/null +++ b/app/api/research/venues/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchVenuesHandler } from "@/lib/research/getResearchVenuesHandler"; + +/** + * + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * + * @param request + */ +export async function GET(request: NextRequest) { + return getResearchVenuesHandler(request); +} diff --git a/app/api/research/web/route.ts b/app/api/research/web/route.ts new file mode 100644 index 00000000..2c470844 --- /dev/null +++ b/app/api/research/web/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postResearchWebHandler } from "@/lib/research/postResearchWebHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/research/web + * + * Search the web for real-time information. + */ +export async function POST(request: NextRequest) { + return postResearchWebHandler(request); +} diff --git a/lib/chartmetric/__tests__/getChartmetricToken.test.ts b/lib/chartmetric/__tests__/getChartmetricToken.test.ts new file mode 100644 index 00000000..63e981e5 --- /dev/null +++ b/lib/chartmetric/__tests__/getChartmetricToken.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getChartmetricToken } from "../getChartmetricToken"; + +describe("getChartmetricToken", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + }); + + it("throws when CHARTMETRIC_REFRESH_TOKEN is not set", async () => { + delete process.env.CHARTMETRIC_REFRESH_TOKEN; + + await expect(getChartmetricToken()).rejects.toThrow("CHARTMETRIC_REFRESH_TOKEN"); + }); + + it("returns 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("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..73e7ea29 --- /dev/null +++ b/lib/chartmetric/getChartmetricToken.ts @@ -0,0 +1,35 @@ +/** + * Exchanges the Chartmetric refresh token for a short-lived access token. + * + * @returns The Chartmetric access token string. + * @throws Error if the token exchange fails or the env variable is missing. + */ +export async function getChartmetricToken(): Promise { + const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN; + + if (!refreshToken) { + throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set"); + } + + const response = await fetch("https://api.chartmetric.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshtoken: refreshToken }), + }); + + if (!response.ok) { + throw new Error(`Chartmetric token exchange failed with status ${response.status}`); + } + + const data = (await response.json()) as { 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"); + } + + return token; +} diff --git a/lib/content/contentTemplates.ts b/lib/content/contentTemplates.ts index 7d0653e3..179c7453 100644 --- a/lib/content/contentTemplates.ts +++ b/lib/content/contentTemplates.ts @@ -20,6 +20,11 @@ export const CONTENT_TEMPLATES: ContentTemplate[] = [ description: "Small venue concert", defaultLipsync: false, }, + { + name: "album-record-store", + description: "Album art on vinyl in a NYC record store", + defaultLipsync: false, + }, ]; /** Derived from the first entry in CONTENT_TEMPLATES to avoid string duplication. */ diff --git a/lib/exa/searchPeople.ts b/lib/exa/searchPeople.ts new file mode 100644 index 00000000..9b56680e --- /dev/null +++ b/lib/exa/searchPeople.ts @@ -0,0 +1,65 @@ +const EXA_BASE_URL = "https://api.exa.ai"; + +export interface ExaPersonResult { + title: string; + url: string; + id: string; + publishedDate?: string; + author?: string; + highlights?: string[]; + summary?: string; +} + +export interface ExaPeopleResponse { + results: ExaPersonResult[]; + requestId: string; +} + +/** + * Searches Exa's people index for individuals matching the query. + * Uses Exa's category: "people" filter for multi-source people data + * including LinkedIn profiles. + * + * @param query - Natural language search (e.g., "A&R reps at Atlantic Records") + * @param numResults - Number of results to return (default 10, max 100) + * @returns People search results with highlights + */ +export async function searchPeople( + query: string, + numResults: number = 10, +): Promise { + const apiKey = process.env.EXA_API_KEY; + + if (!apiKey) { + throw new Error("EXA_API_KEY environment variable is not set"); + } + + const response = await fetch(`${EXA_BASE_URL}/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + query, + category: "people", + numResults, + contents: { + highlights: { maxCharacters: 4000 }, + summary: true, + }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Exa API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + const data = await response.json(); + + return { + results: data.results || [], + requestId: data.requestId || "", + }; +} diff --git a/lib/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..8d79c759 --- /dev/null +++ b/lib/mcp/tools/research/index.ts @@ -0,0 +1,62 @@ +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"; +/** + * 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); +}; diff --git a/lib/mcp/tools/research/registerResearchAlbumsTool.ts b/lib/mcp/tools/research/registerResearchAlbumsTool.ts new file mode 100644 index 00000000..405b6459 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchAlbumsTool.ts @@ -0,0 +1,49 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_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 => { + 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; + 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..3a03d34f --- /dev/null +++ b/lib/mcp/tools/research/registerResearchArtistTool.ts @@ -0,0 +1,46 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_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 => { + 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}`); + } + 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..ce4dce06 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchAudienceTool.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 { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + platform: z + .enum(["instagram", "tiktok", "youtube"]) + .optional() + .default("instagram") + .describe("Platform for audience data (default: instagram)"), +}); + +/** + * Registers the "research_audience" tool on the MCP server. + * Returns audience demographics — age, gender, and country breakdown. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchAudienceTool(server: McpServer): void { + server.registerTool( + "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 => { + 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}`); + } + 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..a472deb3 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCareerTool.ts @@ -0,0 +1,46 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_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 => { + 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}`); + } + 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..30fae648 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchChartsTool.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 { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + platform: z + .string() + .describe("Chart platform: spotify, applemusic, tiktok, youtube, itunes, shazam, etc."), + country: z.string().optional().describe("Two-letter country code (e.g. US, GB, DE)"), + interval: z.string().optional().describe("Time interval (e.g. daily, weekly)"), + type: z.string().optional().describe("Chart type (varies by platform)"), + latest: z + .boolean() + .optional() + .default(true) + .describe("Return only the latest chart (default: true)"), +}); + +/** + * Registers the "research_charts" tool on the MCP server. + * Returns global chart positions for a given platform. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchChartsTool(server: McpServer): void { + server.registerTool( + "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 => { + 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}`); + } + 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..8ef57695 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCitiesTool.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"; + +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 => { + 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); + + 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..8f7dcb6d --- /dev/null +++ b/lib/mcp/tools/research/registerResearchCuratorTool.ts @@ -0,0 +1,48 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const 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 => { + 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}`); + } + 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..a8647f89 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchDiscoverTool.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 { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + country: z.string().optional().describe("Two-letter country code (e.g. US, GB, DE)"), + genre: z.number().optional().describe("Genre tag ID from research_genres"), + sp_monthly_listeners_min: z.number().optional().describe("Minimum Spotify monthly listeners"), + sp_monthly_listeners_max: z.number().optional().describe("Maximum Spotify monthly listeners"), + sort: z.string().optional().describe("Sort column (e.g. sp_monthly_listeners, sp_followers)"), + limit: z + .number() + .optional() + .default(20) + .describe("Maximum number of artists to return (default: 20)"), +}); + +/** + * Registers the "research_discover" tool on the MCP server. + * Discovers artists by criteria — country, genre, listener count, and growth rate. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchDiscoverTool(server: McpServer): void { + server.registerTool( + "discover_artists", + { + description: + "Discover artists by criteria — filter by country, genre, listener count, follower count, and growth rate.", + inputSchema: schema, + }, + async args => { + try { + const queryParams: Record = {}; + + if (args.country) queryParams.code2 = args.country; + if (args.genre !== undefined) queryParams.tagId = String(args.genre); + if (args.sp_monthly_listeners_min !== undefined) { + queryParams.sp_monthly_listeners_min = String(args.sp_monthly_listeners_min); + } + if (args.sp_monthly_listeners_max !== undefined) { + queryParams.sp_monthly_listeners_max = String(args.sp_monthly_listeners_max); + } + if (args.sort) queryParams.sortColumn = args.sort; + if (args.limit) queryParams.limit = String(args.limit); + + const result = await proxyToChartmetric("/artist/list/filter", queryParams); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to discover artists", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchEnrichTool.ts b/lib/mcp/tools/research/registerResearchEnrichTool.ts new file mode 100644 index 00000000..69c0c4ba --- /dev/null +++ b/lib/mcp/tools/research/registerResearchEnrichTool.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { enrichEntity } from "@/lib/parallel/enrichEntity"; + +const schema = z.object({ + input: z.string().describe("What to research"), + schema: z + .record(z.unknown()) + .describe("JSON schema defining the output fields to extract"), + processor: z + .enum(["base", "core", "ultra"]) + .optional() + .default("base") + .describe( + "Processing tier: base (fast), core (balanced), ultra (comprehensive)", + ), +}); + +/** + * Registers the "research_enrich" tool on the MCP server. + * Enriches an entity with structured data from web research using Parallel's task API. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchEnrichTool(server: McpServer): void { + server.registerTool( + "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) => { + try { + const result = await enrichEntity( + args.input, + args.schema as Record, + args.processor ?? "base", + ); + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to enrich entity", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchExtractTool.ts b/lib/mcp/tools/research/registerResearchExtractTool.ts new file mode 100644 index 00000000..007880d0 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchExtractTool.ts @@ -0,0 +1,55 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { extractUrl } from "@/lib/parallel/extractUrl"; + +const schema = z.object({ + urls: z + .array(z.string()) + .max(10) + .describe("URLs to extract content from (max 10)"), + objective: z + .string() + .optional() + .describe("What information to focus the extraction on"), + full_content: z + .boolean() + .optional() + .describe("Return full page content instead of focused excerpts"), +}); + +/** + * Registers the "research_extract" tool on the MCP server. + * Extracts clean markdown content from public URLs using Parallel's extract API. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchExtractTool(server: McpServer): void { + server.registerTool( + "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) => { + try { + const result = await extractUrl( + args.urls, + args.objective, + args.full_content ?? false, + ); + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to extract URL content", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchFestivalsTool.ts b/lib/mcp/tools/research/registerResearchFestivalsTool.ts new file mode 100644 index 00000000..4e598695 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchFestivalsTool.ts @@ -0,0 +1,36 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({}); + +/** + * Registers the "research_festivals" tool on the MCP server. + * Lists music festivals. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchFestivalsTool(server: McpServer): void { + server.registerTool( + "get_festivals", + { + description: "List music festivals.", + inputSchema: schema, + }, + async () => { + try { + const result = await proxyToChartmetric("/festival/list"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch festivals", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchGenresTool.ts b/lib/mcp/tools/research/registerResearchGenresTool.ts new file mode 100644 index 00000000..758b7c85 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchGenresTool.ts @@ -0,0 +1,37 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({}); + +/** + * Registers the "research_genres" tool on the MCP server. + * Lists all available genre IDs and names for use with the discover tool. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchGenresTool(server: McpServer): void { + server.registerTool( + "get_genres", + { + description: + "List all available genre IDs and names. Use these IDs with the research_discover tool.", + inputSchema: schema, + }, + async () => { + try { + const result = await proxyToChartmetric("/genres"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch genres", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchInsightsTool.ts b/lib/mcp/tools/research/registerResearchInsightsTool.ts new file mode 100644 index 00000000..96d7f16c --- /dev/null +++ b/lib/mcp/tools/research/registerResearchInsightsTool.ts @@ -0,0 +1,49 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_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 => { + 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; + 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..14fae48b --- /dev/null +++ b/lib/mcp/tools/research/registerResearchInstagramPostsTool.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_instagram_posts" tool on the MCP server. + * Returns an artist's top Instagram posts and reels sorted by engagement. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchInstagramPostsTool(server: McpServer): void { + server.registerTool( + "get_artist_instagram_posts", + { + description: "Get an artist's top Instagram posts and reels sorted by engagement.", + inputSchema: schema, + }, + async args => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric( + `/SNS/deepSocial/cm_artist/${resolved.id}/instagram`, + ); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch Instagram posts", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchLookupTool.ts b/lib/mcp/tools/research/registerResearchLookupTool.ts new file mode 100644 index 00000000..86380d4a --- /dev/null +++ b/lib/mcp/tools/research/registerResearchLookupTool.ts @@ -0,0 +1,44 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + url: z.string().describe("Spotify URL or platform ID"), +}); + +/** + * Registers the "research_lookup" tool on the MCP server. + * Looks up an artist by a Spotify URL or platform ID and returns the artist profile. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchLookupTool(server: McpServer): void { + server.registerTool( + "lookup_artist_by_url", + { + description: "Look up an artist by a Spotify URL or platform ID. Returns the artist profile.", + inputSchema: schema, + }, + async args => { + try { + const spotifyId = args.url.split("/").pop()?.split("?")[0]; + + if (!spotifyId) { + return getToolResultError("Could not extract Spotify ID from URL"); + } + + const result = await proxyToChartmetric(`/artist/spotify/${spotifyId}/get-ids`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to 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..9baf7137 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchMetricsTool.ts @@ -0,0 +1,53 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + source: z + .string() + .describe( + "Platform: spotify, instagram, tiktok, youtube_channel, soundcloud, deezer, twitter, facebook, etc.", + ), +}); + +/** + * Registers the "research_metrics" tool on the MCP server. + * Fetches streaming and social metrics for an artist on a specific platform. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchMetricsTool(server: McpServer): void { + server.registerTool( + "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 => { + 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}`); + } + 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..19e81086 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchMilestonesTool.ts @@ -0,0 +1,48 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_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 => { + 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 any)?.insights || []; + return getToolResultSuccess({ milestones }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch milestones", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPeopleTool.ts b/lib/mcp/tools/research/registerResearchPeopleTool.ts new file mode 100644 index 00000000..825f5118 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPeopleTool.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { searchPeople } from "@/lib/exa/searchPeople"; + +const schema = z.object({ + query: z.string().describe("Search query for people"), + num_results: z + .number() + .optional() + .default(10) + .describe("Number of results to return (default: 10)"), +}); + +/** + * Registers the "research_people" tool on the MCP server. + * Searches for people in the music industry using Exa's people index. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchPeopleTool(server: McpServer): void { + server.registerTool( + "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) => { + try { + const result = await searchPeople( + args.query, + args.num_results ?? 10, + ); + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error + ? error.message + : "Failed to search for people", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchPlaylistTool.ts b/lib/mcp/tools/research/registerResearchPlaylistTool.ts new file mode 100644 index 00000000..764e4fc9 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPlaylistTool.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"; + +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 => { + 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}`); + } + 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..71455604 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchPlaylistsTool.ts @@ -0,0 +1,92 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const 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 => { + 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; + 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..ddd90af2 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchRadioTool.ts @@ -0,0 +1,39 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({}); + +/** + * Registers the "research_radio" tool on the MCP server. + * Returns the list of radio stations tracked by Chartmetric. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchRadioTool(server: McpServer): void { + server.registerTool( + "get_radio_stations", + { + description: + "List radio stations tracked by Chartmetric. " + + "Returns station names, formats, and markets.", + inputSchema: schema, + }, + async () => { + try { + const result = await proxyToChartmetric("/radio/station-list"); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const stations = Array.isArray(result.data) ? result.data : []; + return getToolResultSuccess({ stations }); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Failed to fetch radio stations", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchRankTool.ts b/lib/mcp/tools/research/registerResearchRankTool.ts new file mode 100644 index 00000000..98031fc6 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchRankTool.ts @@ -0,0 +1,45 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_rank" tool on the MCP server. + * Returns the artist's global Chartmetric ranking. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchRankTool(server: McpServer): void { + server.registerTool( + "get_artist_rank", + { + description: + "Get an artist's global Chartmetric ranking. " + "Returns a single integer rank value.", + inputSchema: schema, + }, + async args => { + try { + const resolved = await resolveArtist(args.artist); + + if (resolved.error) { + return getToolResultError(resolved.error); + } + + const result = await proxyToChartmetric(`/artist/${resolved.id}/artist-rank`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + const rank = (result.data as any)?.artist_rank || null; + return getToolResultSuccess({ rank }); + } catch (error) { + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch rank"); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchSimilarTool.ts b/lib/mcp/tools/research/registerResearchSimilarTool.ts new file mode 100644 index 00000000..538685a9 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchSimilarTool.ts @@ -0,0 +1,76 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), + audience: z.string().optional().describe("Audience overlap weight: high, medium, or low"), + genre: z.string().optional().describe("Genre similarity weight: high, medium, or low"), + mood: z.string().optional().describe("Mood similarity weight: high, medium, or low"), + musicality: z.string().optional().describe("Musicality similarity weight: high, medium, or low"), + limit: z + .number() + .optional() + .default(10) + .describe("Maximum number of similar artists to return (default: 10)"), +}); + +/** + * Registers the "research_similar" tool on the MCP server. + * Finds similar artists using audience overlap, genre, mood, and musicality weights. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchSimilarTool(server: McpServer): void { + server.registerTool( + "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 => { + 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; + 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..3560d7f5 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTrackTool.ts @@ -0,0 +1,51 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + q: z.string().describe("Track name or Spotify URL"), +}); + +/** + * Registers the "research_track" tool on the MCP server. + * Searches for a track by name or URL and returns its metadata. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchTrackTool(server: McpServer): void { + server.registerTool( + "get_track_info", + { + description: "Get track metadata — title, artist, album, release date, and popularity.", + inputSchema: schema, + }, + async args => { + try { + const searchResult = await proxyToChartmetric("/search", { + q: args.q, + type: "tracks", + limit: "1", + }); + if (searchResult.status !== 200) { + return getToolResultError(`Request failed with status ${searchResult.status}`); + } + + const tracks = searchResult.data as Record[]; + if (!Array.isArray(tracks) || tracks.length === 0) { + return getToolResultError(`No track found for "${args.q}"`); + } + + const trackId = (tracks[0] as Record).id; + const result = await proxyToChartmetric(`/track/${trackId}`); + if (result.status !== 200) { + return getToolResultError(`Request failed with status ${result.status}`); + } + return getToolResultSuccess(result.data); + } catch (error) { + return getToolResultError(error instanceof Error ? error.message : "Failed to fetch track"); + } + }, + ); +} diff --git a/lib/mcp/tools/research/registerResearchTracksTool.ts b/lib/mcp/tools/research/registerResearchTracksTool.ts new file mode 100644 index 00000000..d4f48d34 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchTracksTool.ts @@ -0,0 +1,49 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_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 => { + 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; + 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..5812cac4 --- /dev/null +++ b/lib/mcp/tools/research/registerResearchUrlsTool.ts @@ -0,0 +1,44 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_urls" tool on the MCP server. + * Returns all social and streaming URLs for an artist. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerResearchUrlsTool(server: McpServer): void { + server.registerTool( + "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 => { + 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}`); + } + 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..39bfe17a --- /dev/null +++ b/lib/mcp/tools/research/registerResearchVenuesTool.ts @@ -0,0 +1,48 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const schema = z.object({ + artist: z.string().describe("Artist name to research"), +}); + +/** + * Registers the "research_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 => { + 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 : []; + 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..77b75c93 --- /dev/null +++ b/lib/parallel/enrichEntity.ts @@ -0,0 +1,94 @@ +const PARALLEL_BASE_URL = "https://api.parallel.ai/v1"; + +export interface EnrichResult { + run_id: string; + status: string; + output: unknown; + citations?: Array<{ url: string; title?: string; field?: string }>; +} + +/** + * Enriches an entity with structured data from web research. + * Creates a task run and uses the blocking /result endpoint to wait + * for completion (up to timeout seconds). + * + * @param input - What to research (e.g., "Kaash Paige R&B artist") + * @param outputSchema - JSON schema for the structured output + * @param processor - Processor tier: "base" (fast), "core" (balanced), "ultra" (deep) + * @param timeout - Max seconds to wait for result (default 120) + */ +export async function enrichEntity( + input: string, + outputSchema: Record, + processor: "base" | "core" | "ultra" = "base", + timeout: number = 120, +): Promise { + const apiKey = process.env.PARALLEL_API_KEY; + + if (!apiKey) { + throw new Error("PARALLEL_API_KEY environment variable is not set"); + } + + const createResponse = await fetch(`${PARALLEL_BASE_URL}/tasks/runs`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + input, + processor, + task_spec: { + output_schema: { + type: "json", + json_schema: outputSchema, + }, + }, + }), + }); + + if (!createResponse.ok) { + const errorText = await createResponse.text(); + throw new Error(`Parallel Task API error: ${createResponse.status}\n${errorText}`); + } + + const taskRun = await createResponse.json(); + const runId = taskRun.run_id; + + if (!runId) { + throw new Error("Parallel Task API did not return a run_id"); + } + + const resultResponse = await fetch( + `${PARALLEL_BASE_URL}/tasks/runs/${runId}/result?timeout=${timeout}`, + { headers: { "x-api-key": apiKey } }, + ); + + if (resultResponse.status === 408) { + return { run_id: runId, status: "timeout", output: null }; + } + + if (resultResponse.status === 404) { + throw new Error("Task run failed or not found"); + } + + if (!resultResponse.ok) { + const errorText = await resultResponse.text(); + throw new Error(`Parallel result fetch failed: ${resultResponse.status}\n${errorText}`); + } + + const resultData = await resultResponse.json(); + const output = resultData.output; + + const citations = (output?.basis || []).flatMap( + (b: { field?: string; citations?: Array<{ url: string; title?: string }> }) => + (b.citations || []).map((c) => ({ ...c, field: b.field })), + ); + + return { + run_id: runId, + status: "completed", + output: output?.content, + citations: citations.length > 0 ? citations : undefined, + }; +} diff --git a/lib/parallel/extractUrl.ts b/lib/parallel/extractUrl.ts new file mode 100644 index 00000000..948a858a --- /dev/null +++ b/lib/parallel/extractUrl.ts @@ -0,0 +1,57 @@ +const PARALLEL_BASE_URL = "https://api.parallel.ai/v1beta"; + +export interface ExtractResult { + url: string; + title: string | null; + publish_date: string | null; + excerpts: string[] | null; + full_content: string | null; +} + +export interface ExtractResponse { + extract_id: string; + results: ExtractResult[]; + errors: Array<{ url: string; error: string }>; +} + +/** + * Extracts clean markdown content from one or more public URLs. + * Handles JavaScript-heavy pages and PDFs. Returns focused excerpts + * aligned to an objective, or full page content. + * + * @param urls - URLs to extract (max 10 per request) + * @param objective - What information to focus on (optional, max 3000 chars) + * @param fullContent - Return full page content instead of excerpts + */ +export async function extractUrl( + urls: string[], + objective?: string, + fullContent: boolean = false, +): Promise { + const apiKey = process.env.PARALLEL_API_KEY; + + if (!apiKey) { + throw new Error("PARALLEL_API_KEY environment variable is not set"); + } + + const response = await fetch(`${PARALLEL_BASE_URL}/extract`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + urls, + ...(objective && { objective }), + excerpts: !fullContent, + full_content: fullContent, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Parallel Extract API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + return await response.json(); +} diff --git a/lib/research/__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..49b85191 --- /dev/null +++ b/lib/research/getResearchAlbumsHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/albums + * + * Returns the album discography for the given artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchAlbumsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/albums`, + 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..9aac2278 --- /dev/null +++ b/lib/research/getResearchAudienceHandler.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/audience + * + * Returns audience demographic stats for the given artist on a specific platform. + * Accepts optional `platform` query param (defaults to "instagram"). + * The platform is embedded in the path, not passed as a query param. + * + * @param request + */ +export async function getResearchAudienceHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform") || "instagram"; + + return handleArtistResearch(request, cmId => `/artist/${cmId}/${platform}-audience-stats`); +} diff --git a/lib/research/getResearchCareerHandler.ts b/lib/research/getResearchCareerHandler.ts new file mode 100644 index 00000000..ecc6cbe5 --- /dev/null +++ b/lib/research/getResearchCareerHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/career + * + * Returns career history and milestones for the given artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchCareerHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/career`, + 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..7f9a7130 --- /dev/null +++ b/lib/research/getResearchChartsHandler.ts @@ -0,0 +1,39 @@ +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 + */ +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() }, + ); + } + + return handleResearchRequest( + request, + () => `/charts/${platform}`, + (sp) => { + const params: Record = {}; + const country = sp.get("country"); + if (country) params.country_code = country; + const interval = sp.get("interval"); + if (interval) params.interval = interval; + const type = sp.get("type"); + if (type) params.type = type; + params.latest = sp.get("latest") ?? "true"; + return params; + }, + ); +} diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts new file mode 100644 index 00000000..cd6300e3 --- /dev/null +++ b/lib/research/getResearchCitiesHandler.ts @@ -0,0 +1,30 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/cities + * + * Returns geographic listening data showing where people listen to the artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchCitiesHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/where-people-listen`, + 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..1ec9dcaa --- /dev/null +++ b/lib/research/getResearchCuratorHandler.ts @@ -0,0 +1,28 @@ +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 + */ +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..d523e6d0 --- /dev/null +++ b/lib/research/getResearchDiscoverHandler.ts @@ -0,0 +1,33 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/discover + * + * Discover artists by criteria — country, genre, listener ranges, growth rate. + * + * @param request + */ +export async function getResearchDiscoverHandler(request: NextRequest) { + return handleResearchRequest( + request, + () => "/artist/list/filter", + (sp) => { + const params: Record = {}; + const country = sp.get("country"); + if (country) params.code2 = country; + const genre = sp.get("genre"); + if (genre) params.tagId = genre; + const sort = sp.get("sort"); + if (sort) params.sortColumn = sort; + const limit = sp.get("limit"); + if (limit) params.limit = limit; + const min = sp.get("sp_monthly_listeners_min"); + if (min) params["sp_ml[]"] = min; + const max = sp.get("sp_monthly_listeners_max"); + if (max) params["sp_ml[]"] = max; + return params; + }, + (data) => ({ artists: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts new file mode 100644 index 00000000..45231564 --- /dev/null +++ b/lib/research/getResearchFestivalsHandler.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/festivals + * + * Returns a list of music festivals. + * + * @param request + */ +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..7d925c7b --- /dev/null +++ b/lib/research/getResearchGenresHandler.ts @@ -0,0 +1,18 @@ +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 + */ +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..4712145b --- /dev/null +++ b/lib/research/getResearchInsightsHandler.ts @@ -0,0 +1,20 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/insights + * + * Returns noteworthy insights and highlights for the given artist + * (e.g., trending metrics, chart movements, notable playlist adds). + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchInsightsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/noteworthy-insights`, + 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..f9c0989a --- /dev/null +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -0,0 +1,15 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/instagram-posts + * + * Returns recent Instagram posts for the given artist via Chartmetric's + * DeepSocial integration. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchInstagramPostsHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`); +} diff --git a/lib/research/getResearchLookupHandler.ts b/lib/research/getResearchLookupHandler.ts new file mode 100644 index 00000000..bb1d5889 --- /dev/null +++ b/lib/research/getResearchLookupHandler.ts @@ -0,0 +1,67 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([a-zA-Z0-9]+)/; + +/** + * GET /api/research/lookup + * + * Resolves a Spotify artist URL to Chartmetric IDs. Extracts the Spotify artist ID + * from the given URL and calls Chartmetric's get-ids endpoint to retrieve all + * cross-platform identifiers. + * + * @param request - Requires `url` query param containing a Spotify artist URL + */ +export async function getResearchLookupHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const url = searchParams.get("url"); + + if (!url) { + return NextResponse.json( + { status: "error", error: "url parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const match = url.match(SPOTIFY_ARTIST_REGEX); + if (!match) { + return NextResponse.json( + { status: "error", error: "url must be a valid Spotify artist URL" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const spotifyId = match[1]; + + 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 + } + + return NextResponse.json( + { + status: "success", + ...(typeof result.data === "object" && result.data !== null + ? result.data + : { data: result.data }), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts new file mode 100644 index 00000000..a39ef2d7 --- /dev/null +++ b/lib/research/getResearchMetricsHandler.ts @@ -0,0 +1,26 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/metrics + * + * Returns platform-specific streaming/social metrics for the given artist. + * Requires `artist` and `source` query params. Source is a platform like + * "spotify", "youtube", "instagram", etc. and is embedded in the path. + * + * @param request + */ +export async function getResearchMetricsHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + const source = searchParams.get("source"); + + if (!source) { + return NextResponse.json( + { status: "error", error: "source parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return handleArtistResearch(request, cmId => `/artist/${cmId}/stat/${source}`); +} diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts new file mode 100644 index 00000000..bb274dfa --- /dev/null +++ b/lib/research/getResearchMilestonesHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/milestones + * + * Returns an artist's activity feed — playlist adds, chart entries, and other + * notable events tracked by Chartmetric. + * + * @param request + */ +export async function getResearchMilestonesHandler(request: NextRequest) { + return handleArtistResearch( + request, + (cmId) => `/artist/${cmId}/milestones`, + undefined, + (data) => ({ milestones: (data as any)?.insights || [] }), + ); +} diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts new file mode 100644 index 00000000..9250e26d --- /dev/null +++ b/lib/research/getResearchPlaylistHandler.ts @@ -0,0 +1,86 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/playlist + * + * Returns details for a specific playlist by platform and ID. + * + * @param request - Requires `platform` and `id` query params + */ +export async function getResearchPlaylistHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const platform = searchParams.get("platform"); + const id = searchParams.get("id"); + + if (!platform || !id) { + return NextResponse.json( + { status: "error", error: "platform and id parameters are required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + 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", + }); + + 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..69ceac0e --- /dev/null +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -0,0 +1,70 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/playlists + * + * Returns playlists featuring the given artist on a specific platform. + * Query params: + * - `platform` — streaming platform (default: "spotify") + * - `status` — "current" or "past" (default: "current") + * - `limit` — max results + * - `sort` — mapped to Chartmetric's `sortColumn` + * - `since` — date filter + * - `editorial` — filter to editorial playlists (boolean string) + * + * @param request - Incoming request with query params + * @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..c2923346 --- /dev/null +++ b/lib/research/getResearchProfileHandler.ts @@ -0,0 +1,14 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/profile + * + * Returns the full Chartmetric artist profile for the given artist. + * Requires `artist` query param (name, numeric ID, or UUID). + * + * @param request + */ +export async function getResearchProfileHandler(request: NextRequest) { + return handleArtistResearch(request, cmId => `/artist/${cmId}`); +} diff --git a/lib/research/getResearchRadioHandler.ts b/lib/research/getResearchRadioHandler.ts new file mode 100644 index 00000000..b2159734 --- /dev/null +++ b/lib/research/getResearchRadioHandler.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { handleResearchRequest } from "@/lib/research/handleResearchRequest"; + +/** + * GET /api/research/radio + * + * Returns a list of radio stations. + * + * @param request + */ +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..0d668f26 --- /dev/null +++ b/lib/research/getResearchRankHandler.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/rank + * + * Returns the artist's global Chartmetric ranking. + * + * @param request + */ +export async function getResearchRankHandler(request: NextRequest) { + return handleArtistResearch( + request, + (cmId) => `/artist/${cmId}/artist-rank`, + undefined, + (data) => ({ rank: (data as any)?.artist_rank || null }), + ); +} diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts new file mode 100644 index 00000000..4e41f8b8 --- /dev/null +++ b/lib/research/getResearchSearchHandler.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research + * + * Search for artists by name. Returns matching results with profile summaries. + * + * @param request - The incoming request with query params: q, type, limit + */ +export async function getResearchSearchHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q"); + const type = searchParams.get("type") || "artists"; + const limit = searchParams.get("limit") || "10"; + + if (!q) { + return NextResponse.json( + { status: "error", error: "q parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + 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..4d908659 --- /dev/null +++ b/lib/research/getResearchSimilarHandler.ts @@ -0,0 +1,44 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +const CONFIG_PARAMS = ["audience", "genre", "mood", "musicality"] as const; + +/** + * GET /api/research/similar + * + * Returns similar artists. Uses the configuration-based endpoint when any + * of audience, genre, mood, or musicality params are provided (values: high/medium/low). + * Falls back to the simpler related-artists endpoint when none are present. + * Accepts optional `limit` query param. + * + * @param request + */ +export async function getResearchSimilarHandler(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const hasConfigParams = CONFIG_PARAMS.some(p => searchParams.has(p)); + + return handleArtistResearch( + request, + cmId => + hasConfigParams + ? `/artist/${cmId}/similar-artists/by-configurations` + : `/artist/${cmId}/relatedartists`, + sp => { + const params: Record = {}; + for (const key of CONFIG_PARAMS) { + const val = sp.get(key); + if (val) params[key] = val; + } + const limit = sp.get("limit"); + if (limit) params.limit = limit; + return params; + }, + (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..ce5a3288 --- /dev/null +++ b/lib/research/getResearchTrackHandler.ts @@ -0,0 +1,79 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +/** + * GET /api/research/track + * + * Searches for a track by name and returns full details. First searches + * Chartmetric for the track, then fetches the full track profile using + * the matched track ID. + * + * @param request - Requires `q` query param with the track search query + */ +export async function getResearchTrackHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q"); + + if (!q) { + return NextResponse.json( + { status: "error", error: "q parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + 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..fbe336e2 --- /dev/null +++ b/lib/research/getResearchTracksHandler.ts @@ -0,0 +1,19 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/tracks + * + * Returns all tracks for the given artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchTracksHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/tracks`, + 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..d0d10dfa --- /dev/null +++ b/lib/research/getResearchUrlsHandler.ts @@ -0,0 +1,27 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/urls + * + * Returns all known platform URLs (Spotify, Apple Music, YouTube, socials, etc.) + * for the given artist. + * Requires `artist` query param. + * + * @param request + */ +export async function getResearchUrlsHandler(request: NextRequest) { + return handleArtistResearch( + request, + cmId => `/artist/${cmId}/urls`, + 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..692eaa1d --- /dev/null +++ b/lib/research/getResearchVenuesHandler.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; + +/** + * GET /api/research/venues + * + * Returns venues the artist has performed at, including capacity and location. + * + * @param request + */ +export async function getResearchVenuesHandler(request: NextRequest) { + return handleArtistResearch( + request, + (cmId) => `/artist/${cmId}/venues`, + undefined, + (data) => ({ venues: Array.isArray(data) ? data : [] }), + ); +} diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts new file mode 100644 index 00000000..fa89b608 --- /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 - The incoming request + * @param buildPath - Function that takes the resolved Chartmetric ID and returns the API path + * @param getQueryParams - Optional function to extract additional query params from the request + * @param transformResponse - Optional function to reshape the response data + */ +export async function handleArtistResearch( + request: NextRequest, + buildPath: (cmId: number) => string, + getQueryParams?: (searchParams: URLSearchParams) => Record, + transformResponse?: (data: unknown) => unknown, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + const { searchParams } = new URL(request.url); + const artist = searchParams.get("artist"); + + if (!artist) { + return NextResponse.json( + { status: "error", error: "artist parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const resolved = await resolveArtist(artist); + if (resolved.error) { + return NextResponse.json( + { status: "error", error: resolved.error }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + 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 + ? 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..9f8dbbee --- /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 - The incoming request + * @param buildPath - Function that returns the Chartmetric API path + * @param getQueryParams - Optional function to extract query params from the request + * @param transformResponse - Optional function to reshape the response data + * @param credits - Number of credits to deduct (default 5) + */ +export async function handleResearchRequest( + request: NextRequest, + buildPath: () => string, + getQueryParams?: (searchParams: URLSearchParams) => Record, + transformResponse?: (data: unknown) => unknown, + credits: number = 5, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const { accountId } = authResult; + + 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..42538916 --- /dev/null +++ b/lib/research/postResearchDeepHandler.ts @@ -0,0 +1,66 @@ +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"), +}); + +/** + * POST /api/research/deep + * + * Perform deep, comprehensive research on a topic. Browses multiple + * sources extensively and returns a cited report. + * + * @param request - Body: { query } + * @returns JSON success or error response + */ +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..76060181 --- /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, + research_basis: result.research_basis, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Enrichment failed", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/research/postResearchExtractHandler.ts b/lib/research/postResearchExtractHandler.ts new file mode 100644 index 00000000..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..47b6014f --- /dev/null +++ b/lib/research/postResearchWebHandler.ts @@ -0,0 +1,73 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { searchPerplexity } from "@/lib/perplexity/searchPerplexity"; +import { formatSearchResultsAsMarkdown } from "@/lib/perplexity/formatSearchResultsAsMarkdown"; + +const bodySchema = z.object({ + query: z.string().min(1, "query is required"), + max_results: z.coerce.number().int().min(1).max(20).optional(), + country: z.string().length(2).optional(), +}); + +/** + * POST /api/research/web + * + * Search the web for real-time information. Returns ranked results + * with titles, URLs, and content snippets. + * + * @param request - Body: { query, max_results?, country? } + * @returns JSON success or error response + */ +export async function postResearchWebHandler(request: NextRequest): Promise { + 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..d706ffbe --- /dev/null +++ b/lib/research/proxyToChartmetric.ts @@ -0,0 +1,53 @@ +import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; + +const CHARTMETRIC_BASE = "https://api.chartmetric.com/api"; + +interface ProxyResult { + data: unknown; + status: number; +} + +/** + * Proxies a request to the Chartmetric API with authentication. + * Returns the parsed JSON response with the `obj` wrapper stripped. + * + * @param path - Chartmetric API path (e.g., "/artist/3380/stat/spotify") + * @param queryParams - Optional query parameters to append + * @returns The response data (contents of `obj` if present, otherwise full response) + */ +export async function proxyToChartmetric( + path: string, + queryParams?: Record, +): Promise { + const accessToken = await getChartmetricToken(); + + const url = new URL(`${CHARTMETRIC_BASE}${path}`); + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== "") { + url.searchParams.set(key, value); + } + } + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + return { + data: { error: `Chartmetric API returned ${response.status}` }, + status: response.status, + }; + } + + const json = await response.json(); + + const data = json.obj !== undefined ? json.obj : json; + + return { data, status: response.status }; +} diff --git a/lib/research/resolveArtist.ts b/lib/research/resolveArtist.ts new file mode 100644 index 00000000..86b76bbf --- /dev/null +++ b/lib/research/resolveArtist.ts @@ -0,0 +1,53 @@ +import { proxyToChartmetric } from "@/lib/research/proxyToChartmetric"; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Resolves an artist identifier (name, UUID, or numeric ID) to a Chartmetric artist ID. + * + * - Numeric string → used directly as Chartmetric ID + * - UUID → future: look up mapping. For now, returns error. + * - String → searches Chartmetric by name, returns top match ID + * + * @param artist - Artist name, Recoup artist ID (UUID), or numeric ID + * @returns The Chartmetric artist ID, or null if not found + */ +export async function resolveArtist( + artist: string, +): Promise<{ id: number; error?: never } | { id?: never; error: string }> { + if (!artist || !artist.trim()) { + return { error: "artist parameter is required" }; + } + + const trimmed = artist.trim(); + + if (/^\d+$/.test(trimmed)) { + return { id: parseInt(trimmed, 10) }; + } + + if (UUID_REGEX.test(trimmed)) { + // TODO: Look up Recoup artist ID → Chartmetric ID mapping in database + return { + error: "Recoup artist ID resolution is not yet implemented. Use an artist name instead.", + }; + } + + const result = await proxyToChartmetric("/search", { + q: trimmed, + type: "artists", + limit: "1", + }); + + if (result.status !== 200) { + return { error: `Search failed with status ${result.status}` }; + } + + const data = result.data as { artists?: Array<{ id: number; name: string }> }; + const artists = data?.artists; + + if (!artists || artists.length === 0) { + return { error: `No artist found matching "${trimmed}"` }; + } + + return { id: artists[0].id }; +}