From 4727687cf0bfcba0b32c98db0c96d8af9e965413 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:33:58 -0400 Subject: [PATCH 01/21] Migrate research provider to SongStats --- .env.example | 6 + app/api/research/albums/route.ts | 2 +- app/api/research/track/playlists/route.ts | 2 +- app/api/research/track/route.ts | 2 +- .../getResearchAlbumsHandler.test.ts | 2 +- .../getResearchChartsHandler.test.ts | 14 +- .../getResearchDiscoverHandler.test.ts | 14 +- .../getResearchSearchHandler.test.ts | 4 +- .../getResearchSimilarHandler.test.ts | 20 +- .../__tests__/getResearchTrackHandler.test.ts | 2 +- .../getResearchTrackPlaylistsHandler.test.ts | 2 +- .../__tests__/handleArtistResearch.test.ts | 46 ++- lib/research/__tests__/handleResearch.test.ts | 16 +- lib/research/__tests__/resolveArtist.test.ts | 35 +- lib/research/__tests__/resolveTrack.test.ts | 19 +- .../__tests__/validateArtistRequest.test.ts | 12 + .../validateGetResearchAlbumsRequest.test.ts | 18 +- .../validateGetResearchCuratorRequest.test.ts | 2 +- .../validateGetResearchLookupRequest.test.ts | 10 + .../validateGetResearchTrackRequest.test.ts | 14 +- lib/research/ensureResearchCredits.ts | 2 +- lib/research/getResearchAlbumsHandler.ts | 10 +- lib/research/getResearchFestivalsHandler.ts | 2 +- .../getResearchInstagramPostsHandler.ts | 4 +- lib/research/getResearchLookupHandler.ts | 4 +- lib/research/getResearchMilestonesHandler.ts | 2 +- lib/research/getResearchPlaylistHandler.ts | 5 +- lib/research/getResearchProfileHandler.ts | 2 +- lib/research/getResearchRankHandler.ts | 2 +- lib/research/getResearchSearchHandler.ts | 2 +- lib/research/getResearchTrackHandler.ts | 7 +- .../getResearchTrackPlaylistsHandler.ts | 2 +- lib/research/handleArtistResearch.ts | 17 +- lib/research/handleResearch.ts | 10 +- lib/research/providers/ResearchProvider.ts | 1 + .../__tests__/fetchResearchProvider.test.ts | 47 +++ .../__tests__/getResearchProvider.test.ts | 29 ++ .../providers/fetchResearchProvider.ts | 19 ++ lib/research/providers/getResearchProvider.ts | 7 + lib/research/resolveArtist.ts | 30 +- lib/research/resolveTrack.ts | 32 +- .../__tests__/fetchSongstatsResearch.test.ts | 85 +++++ .../songstats/fetchSongstatsResearch.ts | 314 ++++++++++++++++++ lib/research/validateArtistRequest.ts | 15 +- .../validateGetResearchAlbumsRequest.ts | 13 +- .../validateGetResearchChartsRequest.ts | 2 +- .../validateGetResearchCuratorRequest.ts | 4 +- .../validateGetResearchLookupRequest.ts | 17 +- .../validateGetResearchSearchRequest.ts | 5 +- .../validateGetResearchTrackRequest.ts | 11 +- .../__tests__/fetchSongstats.test.ts | 63 ++++ lib/songstats/fetchSongstats.ts | 55 +++ lib/songstats/songstatsBase.ts | 2 + 53 files changed, 904 insertions(+), 160 deletions(-) create mode 100644 lib/research/providers/ResearchProvider.ts create mode 100644 lib/research/providers/__tests__/fetchResearchProvider.test.ts create mode 100644 lib/research/providers/__tests__/getResearchProvider.test.ts create mode 100644 lib/research/providers/fetchResearchProvider.ts create mode 100644 lib/research/providers/getResearchProvider.ts create mode 100644 lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts create mode 100644 lib/research/songstats/fetchSongstatsResearch.ts create mode 100644 lib/songstats/__tests__/fetchSongstats.test.ts create mode 100644 lib/songstats/fetchSongstats.ts create mode 100644 lib/songstats/songstatsBase.ts diff --git a/.env.example b/.env.example index 4474d2413..985e8c72a 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,12 @@ RESEND_API_KEY= PERPLEXITY_API_KEY= SERPAPI_API_KEY= ANTHROPIC_API_KEY= +SongStats_API= + +# Research provider override. Defaults to SongStats when unset. +# Set RESEARCH_PROVIDER=chartmetric only for legacy/customer-provided Chartmetric access. +RESEARCH_PROVIDER= +CHARTMETRIC_REFRESH_TOKEN= # Spotify SPOTIFY_CLIENT_ID= diff --git a/app/api/research/albums/route.ts b/app/api/research/albums/route.ts index d42f6aac4..0c9d941b5 100644 --- a/app/api/research/albums/route.ts +++ b/app/api/research/albums/route.ts @@ -12,7 +12,7 @@ export async function OPTIONS() { } /** - * GET /api/research/albums — Album discography for a Chartmetric artist id. + * GET /api/research/albums — Album discography for a provider artist id. * Discovery by name is the caller's job via `GET /api/research`. * * @param request - must include numeric `artist_id` query param diff --git a/app/api/research/track/playlists/route.ts b/app/api/research/track/playlists/route.ts index 0eb5c13ff..dc5065715 100644 --- a/app/api/research/track/playlists/route.ts +++ b/app/api/research/track/playlists/route.ts @@ -14,7 +14,7 @@ export async function OPTIONS() { /** * GET /api/research/track/playlists — Playlists featuring a specific track. Requires `?id=` or `?q=` query param. * - * @param request - must include `id` (Chartmetric track ID) or `q` (track name) query param + * @param request - must include `id` (provider track ID) or `q` (track name) query param * @returns JSON playlist placements for the track or error */ export async function GET(request: NextRequest) { diff --git a/app/api/research/track/route.ts b/app/api/research/track/route.ts index 174df92fe..a2af64b51 100644 --- a/app/api/research/track/route.ts +++ b/app/api/research/track/route.ts @@ -12,7 +12,7 @@ export async function OPTIONS() { } /** - * GET /api/research/track — Full Chartmetric track details by numeric `id`. + * GET /api/research/track — Full provider track details by `id`. * Discovery (search by name) is the caller's job via `GET /api/research`. * * @param request - must include numeric `id` query param diff --git a/lib/research/__tests__/getResearchAlbumsHandler.test.ts b/lib/research/__tests__/getResearchAlbumsHandler.test.ts index f2087b4bb..260c30c64 100644 --- a/lib/research/__tests__/getResearchAlbumsHandler.test.ts +++ b/lib/research/__tests__/getResearchAlbumsHandler.test.ts @@ -65,7 +65,7 @@ describe("getResearchAlbumsHandler", () => { }); }); - it("forwards is_primary=false, limit, and offset to Chartmetric when provided", async () => { + it("forwards is_primary=false, limit, and offset to the provider when provided", async () => { vi.mocked(validateGetResearchAlbumsRequest).mockResolvedValue({ accountId: "test-id", artistId: "3380", diff --git a/lib/research/__tests__/getResearchChartsHandler.test.ts b/lib/research/__tests__/getResearchChartsHandler.test.ts index 9dfc2b434..4cefd3840 100644 --- a/lib/research/__tests__/getResearchChartsHandler.test.ts +++ b/lib/research/__tests__/getResearchChartsHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest } from "next/server"; import { getResearchChartsHandler } from "../getResearchChartsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), @@ -17,8 +17,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), +vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ + fetchResearchProvider: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ @@ -56,7 +56,7 @@ describe("getResearchChartsHandler", () => { }); it("defaults type to 'regional' and interval to 'daily'", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: { chart: [] }, status: 200, }); @@ -64,14 +64,14 @@ describe("getResearchChartsHandler", () => { const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&country=US"); await getResearchChartsHandler(req); - const calledParams = vi.mocked(fetchChartmetric).mock.calls[0][1]; + const calledParams = vi.mocked(fetchResearchProvider).mock.calls[0][1]; expect(calledParams).toHaveProperty("type", "regional"); expect(calledParams).toHaveProperty("interval", "daily"); expect(calledParams).toHaveProperty("country_code", "US"); }); it("preserves user-provided type and interval", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: { chart: [] }, status: 200, }); @@ -81,7 +81,7 @@ describe("getResearchChartsHandler", () => { ); await getResearchChartsHandler(req); - const calledParams = vi.mocked(fetchChartmetric).mock.calls[0][1]; + const calledParams = vi.mocked(fetchResearchProvider).mock.calls[0][1]; expect(calledParams).toMatchObject({ type: "viral", interval: "weekly" }); }); }); diff --git a/lib/research/__tests__/getResearchDiscoverHandler.test.ts b/lib/research/__tests__/getResearchDiscoverHandler.test.ts index cda7c4b9e..dc9d1a564 100644 --- a/lib/research/__tests__/getResearchDiscoverHandler.test.ts +++ b/lib/research/__tests__/getResearchDiscoverHandler.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getResearchDiscoverHandler } from "../getResearchDiscoverHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), @@ -17,8 +17,8 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), +vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ + fetchResearchProvider: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ @@ -79,7 +79,7 @@ describe("getResearchDiscoverHandler", () => { ? Exclude : never); - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: [ { name: "Artist A", sp_monthly_listeners: 100000 }, { name: "Artist B", sp_monthly_listeners: 200000 }, @@ -105,7 +105,7 @@ describe("getResearchDiscoverHandler", () => { ? Exclude : never); - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: [], status: 200, }); @@ -115,7 +115,7 @@ describe("getResearchDiscoverHandler", () => { ); await getResearchDiscoverHandler(req); - expect(fetchChartmetric).toHaveBeenCalledWith( + expect(fetchResearchProvider).toHaveBeenCalledWith( "/artist/list/filter", expect.objectContaining({ "sp_ml[]": "50000,200000" }), ); @@ -130,7 +130,7 @@ describe("getResearchDiscoverHandler", () => { ? Exclude : never); - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: null, status: 500, }); diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts index 013d29197..aeb37ec30 100644 --- a/lib/research/__tests__/getResearchSearchHandler.test.ts +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -67,7 +67,7 @@ describe("getResearchSearchHandler", () => { expect(body.results).toEqual([{ name: "Drake", id: 3380 }]); }); - it("forwards only the defaulted params to Chartmetric when no optional params are provided", async () => { + it("forwards only the defaulted params to the provider when no optional params are provided", async () => { vi.mocked(handleResearch).mockResolvedValue({ data: { artists: [] } }); const req = new NextRequest("http://localhost/api/research/search?q=Drake"); await getResearchSearchHandler(req); @@ -79,7 +79,7 @@ describe("getResearchSearchHandler", () => { }); }); - it("forwards beta, platforms, and offset to Chartmetric when provided", async () => { + it("forwards beta, platforms, and offset to the provider when provided", async () => { vi.mocked(validateGetResearchSearchRequest).mockResolvedValue({ accountId: "test-id", q: "Hotline Bling", diff --git a/lib/research/__tests__/getResearchSimilarHandler.test.ts b/lib/research/__tests__/getResearchSimilarHandler.test.ts index 865ea1f73..ecf12c6b8 100644 --- a/lib/research/__tests__/getResearchSimilarHandler.test.ts +++ b/lib/research/__tests__/getResearchSimilarHandler.test.ts @@ -4,7 +4,7 @@ import { NextRequest } from "next/server"; import { getResearchSimilarHandler } from "../getResearchSimilarHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), @@ -22,8 +22,8 @@ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), +vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ + fetchResearchProvider: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ @@ -38,11 +38,11 @@ describe("getResearchSimilarHandler", () => { orgId: null, authToken: "token", }); - vi.mocked(resolveArtist).mockResolvedValue({ id: 3380 }); + vi.mocked(resolveArtist).mockResolvedValue({ id: "3380" }); }); it("uses by-configurations path with default params when no config params provided", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: [{ id: 100, name: "Kendrick Lamar" }], status: 200, }); @@ -52,13 +52,13 @@ describe("getResearchSimilarHandler", () => { expect(res.status).toBe(200); // Should call by-configurations, NOT relatedartists - const calledPath = vi.mocked(fetchChartmetric).mock.calls[0][0]; + const calledPath = vi.mocked(fetchResearchProvider).mock.calls[0][0]; expect(calledPath).toContain("by-configurations"); expect(calledPath).not.toContain("relatedartists"); }); it("uses by-configurations path when config params are provided", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: [{ id: 100, name: "Kendrick Lamar" }], status: 200, }); @@ -67,12 +67,12 @@ describe("getResearchSimilarHandler", () => { const res = await getResearchSimilarHandler(req); expect(res.status).toBe(200); - const calledPath = vi.mocked(fetchChartmetric).mock.calls[0][0]; + const calledPath = vi.mocked(fetchResearchProvider).mock.calls[0][0]; expect(calledPath).toContain("by-configurations"); }); it("passes default medium values for config params when none provided", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: [], status: 200, }); @@ -80,7 +80,7 @@ describe("getResearchSimilarHandler", () => { const req = new NextRequest("http://localhost/api/research/similar?artist=Drake"); await getResearchSimilarHandler(req); - const calledParams = vi.mocked(fetchChartmetric).mock.calls[0][1]; + const calledParams = vi.mocked(fetchResearchProvider).mock.calls[0][1]; expect(calledParams).toMatchObject({ audience: "medium", genre: "medium", diff --git a/lib/research/__tests__/getResearchTrackHandler.test.ts b/lib/research/__tests__/getResearchTrackHandler.test.ts index bc1138a80..cc7aa5c90 100644 --- a/lib/research/__tests__/getResearchTrackHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackHandler.test.ts @@ -38,7 +38,7 @@ describe("getResearchTrackHandler", () => { expect(res).toBe(err); }); - it("fetches /track/:id from Chartmetric and returns 200 with the data", async () => { + it("fetches /track/:id from the provider and returns 200 with the data", async () => { vi.mocked(handleResearch).mockResolvedValueOnce({ data: { id: 15194376, name: "Hotline Bling", artists: [{ id: 1, name: "Drake" }] }, }); diff --git a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts index 0a8d8fd83..562c7aa2a 100644 --- a/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts +++ b/lib/research/__tests__/getResearchTrackPlaylistsHandler.test.ts @@ -109,7 +109,7 @@ describe("getResearchTrackPlaylistsHandler", () => { expect(res.status).toBe(404); }); - it("returns empty placements when Chartmetric returns non-array", async () => { + it("returns empty placements when the provider returns non-array data", async () => { vi.mocked(handleResearch).mockResolvedValue({ data: null }); const req = new NextRequest("http://localhost/api/research/track/playlists?id=123"); const res = await getResearchTrackPlaylistsHandler(req); diff --git a/lib/research/__tests__/handleArtistResearch.test.ts b/lib/research/__tests__/handleArtistResearch.test.ts index 146320bc9..cc0b6289c 100644 --- a/lib/research/__tests__/handleArtistResearch.test.ts +++ b/lib/research/__tests__/handleArtistResearch.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleArtistResearch } from "../handleArtistResearch"; import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ @@ -12,8 +12,8 @@ vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), +vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ + fetchResearchProvider: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: vi.fn(), @@ -34,13 +34,13 @@ describe("handleArtistResearch", () => { }); expect(result).toEqual({ error: "Artist not found", status: 404 }); - expect(fetchChartmetric).not.toHaveBeenCalled(); + expect(fetchResearchProvider).not.toHaveBeenCalled(); expect(recordCreditDeduction).not.toHaveBeenCalled(); }); it("proxies to the built path and returns { data } on success", async () => { - vi.mocked(resolveArtist).mockResolvedValue({ id: 42 } as never); - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(resolveArtist).mockResolvedValue({ id: "42" } as never); + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: [{ name: "a" }], } as never); @@ -52,7 +52,7 @@ describe("handleArtistResearch", () => { path: id => `/artist/${id}/albums`, }); - expect(fetchChartmetric).toHaveBeenCalledWith("/artist/42/albums", undefined); + expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/42/albums", undefined); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 5, @@ -61,9 +61,9 @@ describe("handleArtistResearch", () => { expect(result).toEqual({ data: [{ name: "a" }] }); }); - it("forwards query params to fetchChartmetric", async () => { - vi.mocked(resolveArtist).mockResolvedValue({ id: 7 } as never); - vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: {} } as never); + it("forwards query params to the provider fetcher", async () => { + vi.mocked(resolveArtist).mockResolvedValue({ id: "7" } as never); + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: {} } as never); await handleArtistResearch({ artist: "X", @@ -72,15 +72,15 @@ describe("handleArtistResearch", () => { query: { limit: "10", platform: "spotify" }, }); - expect(fetchChartmetric).toHaveBeenCalledWith("/artist/7/playlists", { + expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/7/playlists", { limit: "10", platform: "spotify", }); }); it("returns the upstream status as an error when proxy is non-200", async () => { - vi.mocked(resolveArtist).mockResolvedValue({ id: 1 } as never); - vi.mocked(fetchChartmetric).mockResolvedValue({ status: 502, data: null } as never); + vi.mocked(resolveArtist).mockResolvedValue({ id: "1" } as never); + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 502, data: null } as never); const result = await handleArtistResearch({ artist: "X", @@ -93,8 +93,8 @@ describe("handleArtistResearch", () => { }); it("swallows credit-deduction failures and still returns data", async () => { - vi.mocked(resolveArtist).mockResolvedValue({ id: 1 } as never); - vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(resolveArtist).mockResolvedValue({ id: "1" } as never); + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: "ok" } as never); vi.mocked(recordCreditDeduction).mockRejectedValue(new Error("DB down")); const result = await handleArtistResearch({ @@ -105,4 +105,20 @@ describe("handleArtistResearch", () => { expect(result).toEqual({ data: "ok" }); }); + + it("uses an explicit provider artist id without resolving by name", async () => { + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(recordCreditDeduction).mockResolvedValue(undefined as never); + + const result = await handleArtistResearch({ + artist: "artist_123", + artistId: "artist_123", + accountId: "acc_1", + path: id => `/artist/${id}`, + }); + + expect(resolveArtist).not.toHaveBeenCalled(); + expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/artist_123", undefined); + expect(result).toEqual({ data: "ok" }); + }); }); diff --git a/lib/research/__tests__/handleResearch.test.ts b/lib/research/__tests__/handleResearch.test.ts index 50cba136c..40a34978e 100644 --- a/lib/research/__tests__/handleResearch.test.ts +++ b/lib/research/__tests__/handleResearch.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleResearch } from "../handleResearch"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), +vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ + fetchResearchProvider: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: vi.fn(), @@ -21,7 +21,7 @@ describe("handleResearch", () => { }); it("returns { data } on 200 and deducts the default 5 credits", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: [{ id: 1 }], } as never); @@ -33,7 +33,7 @@ describe("handleResearch", () => { query: { country_code: "US" }, }); - expect(fetchChartmetric).toHaveBeenCalledWith("/charts/spotify", { country_code: "US" }); + expect(fetchResearchProvider).toHaveBeenCalledWith("/charts/spotify", { country_code: "US" }); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 5, @@ -43,7 +43,7 @@ describe("handleResearch", () => { }); it("returns { error, status } when proxy is non-200 and skips deduction", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ status: 502, data: null } as never); + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 502, data: null } as never); const result = await handleResearch({ accountId: "acc_1", @@ -55,7 +55,7 @@ describe("handleResearch", () => { }); it("still returns { data } when credit deduction throws", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: "ok" } as never); vi.mocked(recordCreditDeduction).mockRejectedValue(new Error("DB down")); const result = await handleResearch({ @@ -67,7 +67,7 @@ describe("handleResearch", () => { }); it("respects the credits override", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: {} } as never); + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: {} } as never); vi.mocked(recordCreditDeduction).mockResolvedValue(undefined as never); await handleResearch({ diff --git a/lib/research/__tests__/resolveArtist.test.ts b/lib/research/__tests__/resolveArtist.test.ts index a08e20daa..2c737ba7e 100644 --- a/lib/research/__tests__/resolveArtist.test.ts +++ b/lib/research/__tests__/resolveArtist.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { resolveArtist } from "../resolveArtist"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), +vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ + fetchResearchProvider: vi.fn(), })); describe("resolveArtist", () => { @@ -15,8 +15,8 @@ describe("resolveArtist", () => { it("returns numeric ID directly", async () => { const result = await resolveArtist("3380"); - expect(result).toEqual({ id: 3380 }); - expect(fetchChartmetric).not.toHaveBeenCalled(); + expect(result).toEqual({ id: "3380" }); + expect(fetchResearchProvider).not.toHaveBeenCalled(); }); it("returns error for UUID (not yet implemented)", async () => { @@ -26,16 +26,16 @@ describe("resolveArtist", () => { expect(result.error).toContain("not yet implemented"); }); - it("searches Chartmetric by name and returns top match", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + it("searches the configured provider by name and returns top match", async () => { + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: { artists: [{ id: 3380, name: "Drake" }] }, status: 200, }); const result = await resolveArtist("Drake"); - expect(result).toEqual({ id: 3380 }); - expect(fetchChartmetric).toHaveBeenCalledWith("/search", { + expect(result).toEqual({ id: "3380" }); + expect(fetchResearchProvider).toHaveBeenCalledWith("/search", { q: "Drake", type: "artists", limit: "1", @@ -43,7 +43,7 @@ describe("resolveArtist", () => { }); it("returns error when no artist found", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: { artists: [] }, status: 200, }); @@ -55,7 +55,7 @@ describe("resolveArtist", () => { }); it("returns error when search fails", async () => { - vi.mocked(fetchChartmetric).mockResolvedValue({ + vi.mocked(fetchResearchProvider).mockResolvedValue({ data: { error: "failed" }, status: 500, }); @@ -76,6 +76,17 @@ describe("resolveArtist", () => { it("trims whitespace from input", async () => { const result = await resolveArtist(" 3380 "); - expect(result).toEqual({ id: 3380 }); + expect(result).toEqual({ id: "3380" }); + }); + + it("returns SongStats string IDs from provider-backed search results", async () => { + vi.mocked(fetchResearchProvider).mockResolvedValue({ + data: { artists: [{ id: "artist_123", name: "Test Artist" }] }, + status: 200, + }); + + const result = await resolveArtist("Test Artist"); + + expect(result).toEqual({ id: "artist_123" }); }); }); diff --git a/lib/research/__tests__/resolveTrack.test.ts b/lib/research/__tests__/resolveTrack.test.ts index 987e88917..2b7341fd6 100644 --- a/lib/research/__tests__/resolveTrack.test.ts +++ b/lib/research/__tests__/resolveTrack.test.ts @@ -108,6 +108,23 @@ describe("resolveTrack", () => { vi.mocked(handleResearch).mockResolvedValue({ data: {} }); const result = await resolveTrack("q", undefined, accountId); - expect("error" in result && result.error).toContain("Could not resolve Chartmetric ID"); + expect("error" in result && result.error).toContain("Could not resolve provider track ID"); + }); + + it("returns SongStats track IDs when the provider lookup supplies them", async () => { + vi.mocked(getSearch).mockResolvedValue({ + data: { + tracks: { + items: [{ id: "sp1", name: "T", external_ids: { isrc: "ISRC123" } }], + }, + }, + } as never); + vi.mocked(handleResearch).mockResolvedValue({ + data: { songstats_track_id: "track_123" }, + }); + + const result = await resolveTrack("q", undefined, accountId); + + expect("id" in result && result.id).toBe("track_123"); }); }); diff --git a/lib/research/__tests__/validateArtistRequest.test.ts b/lib/research/__tests__/validateArtistRequest.test.ts index 6578b3aea..ea7d4b98b 100644 --- a/lib/research/__tests__/validateArtistRequest.test.ts +++ b/lib/research/__tests__/validateArtistRequest.test.ts @@ -46,4 +46,16 @@ describe("validateArtistRequest", () => { expect(result).toEqual({ accountId: "acc_1", artist: "Drake" }); }); + + it("accepts id as a provider-neutral artist identifier", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + + const result = await validateArtistRequest(new NextRequest("http://x/?id=artist_123")); + + expect(result).toEqual({ + accountId: "acc_1", + artist: "artist_123", + artistId: "artist_123", + }); + }); }); diff --git a/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts b/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts index bf6045b34..a4d01c58a 100644 --- a/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchAlbumsRequest.test.ts @@ -44,13 +44,13 @@ describe("validateGetResearchAlbumsRequest", () => { expect(body.error).toBe("artist_id parameter is required"); }); - it("returns 400 when artist_id is not a positive integer", async () => { + it("returns 400 when artist_id contains unsupported characters", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/albums?artist_id=Drake"); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=bad/id"); const res = await validateGetResearchAlbumsRequest(req); expect((res as NextResponse).status).toBe(400); const body = await (res as NextResponse).json(); - expect(body.error).toBe("artist_id must be a positive integer"); + expect(body.error).toBe("artist_id must be a provider artist ID"); }); it("defaults to is_primary=true and omits pagination when not supplied", async () => { @@ -66,6 +66,18 @@ describe("validateGetResearchAlbumsRequest", () => { }); }); + it("accepts provider-neutral artist IDs", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/albums?artist_id=artist_123"); + const res = await validateGetResearchAlbumsRequest(req); + + expect(res).toMatchObject({ + accountId: "acc_1", + artistId: "artist_123", + isPrimary: "true", + }); + }); + it("accepts is_primary=false to include features/compilations", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest( diff --git a/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts index 93de9c83e..eb9467d61 100644 --- a/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts @@ -78,7 +78,7 @@ describe("validateGetResearchCuratorRequest", () => { if (result instanceof NextResponse) { expect(result.status).toBe(400); const body = await result.json(); - expect(body.error).toBe("id must be a numeric Chartmetric curator ID (e.g. 2 for Spotify)"); + expect(body.error).toBe("id must be a numeric curator ID (e.g. 2 for Spotify)"); } }); diff --git a/lib/research/__tests__/validateGetResearchLookupRequest.test.ts b/lib/research/__tests__/validateGetResearchLookupRequest.test.ts index 307b4d725..46f345f51 100644 --- a/lib/research/__tests__/validateGetResearchLookupRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchLookupRequest.test.ts @@ -49,6 +49,16 @@ describe("validateGetResearchLookupRequest", () => { expect(body.error).toBe("url parameter is required"); }); + it("accepts spotifyId directly", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/lookup?spotifyId=3TVXtAsR1Inumwj472S9r4", + ); + const res = await validateGetResearchLookupRequest(req); + + expect(res).toEqual({ accountId: "acc_1", spotifyId: "3TVXtAsR1Inumwj472S9r4" }); + }); + it("returns 400 when url is not a Spotify artist URL", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest("http://localhost/api/research/lookup?url=https://google.com"); diff --git a/lib/research/__tests__/validateGetResearchTrackRequest.test.ts b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts index 2e59d4406..0b167f285 100644 --- a/lib/research/__tests__/validateGetResearchTrackRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchTrackRequest.test.ts @@ -44,13 +44,13 @@ describe("validateGetResearchTrackRequest", () => { expect(body.error).toBe("id parameter is required"); }); - it("returns 400 when id is not a positive integer", async () => { + it("returns 400 when id contains unsupported characters", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/track?id=abc"); + const req = new NextRequest("http://localhost/api/research/track?id=bad/id"); const res = await validateGetResearchTrackRequest(req); expect((res as NextResponse).status).toBe(400); const body = await (res as NextResponse).json(); - expect(body.error).toBe("id must be a positive integer"); + expect(body.error).toBe("id must be a provider track ID"); }); it("returns the validated request for a numeric id", async () => { @@ -59,4 +59,12 @@ describe("validateGetResearchTrackRequest", () => { const res = await validateGetResearchTrackRequest(req); expect(res).toEqual({ accountId: "acc_1", id: "15194376" }); }); + + it("accepts provider-neutral track IDs", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/track?id=track_123"); + const res = await validateGetResearchTrackRequest(req); + + expect(res).toMatchObject({ accountId: "acc_1", id: "track_123" }); + }); }); diff --git a/lib/research/ensureResearchCredits.ts b/lib/research/ensureResearchCredits.ts index 8b8182e81..b4e78ad9d 100644 --- a/lib/research/ensureResearchCredits.ts +++ b/lib/research/ensureResearchCredits.ts @@ -8,7 +8,7 @@ const RESEARCH_CREDIT_COST = 5; * Per-route credit gate for the read-only research family. Each successful * research call deducts {@link RESEARCH_CREDIT_COST} credits, so we make sure * the account has them (auto-recharging via a saved card if needed) before the - * caller bothers hitting Chartmetric. Returns a 402 NextResponse the route can + * caller bothers hitting the external provider. Returns a 402 NextResponse the route can * `return` directly, or `null` to signal "you're good, proceed." */ export const ensureResearchCredits = (accountId: string) => diff --git a/lib/research/getResearchAlbumsHandler.ts b/lib/research/getResearchAlbumsHandler.ts index 8f0e14403..a3dfdb5e9 100644 --- a/lib/research/getResearchAlbumsHandler.ts +++ b/lib/research/getResearchAlbumsHandler.ts @@ -7,15 +7,9 @@ import { validateGetResearchAlbumsRequest } from "@/lib/research/validateGetRese /** * GET /api/research/albums * - * Returns the album discography for the given Chartmetric `artist_id`. Thin - * proxy over Chartmetric's `/artist/:id/albums`. By default `isPrimary=true` - * is sent upstream so only albums where the artist is a main artist are - * returned — callers can opt into feature appearances and DJ compilations - * with `is_primary=false`. Discovery by name is the caller's job via - * `GET /api/research?type=artists&beta=true`. + * Returns the album discography for the given provider `artist_id`. * - * @param request - must include numeric `artist_id`; optional `is_primary`, - * `limit`, `offset` + * @param request - must include `artist_id`; optional `is_primary`, `limit`, `offset` * @returns JSON album list or error */ export async function getResearchAlbumsHandler(request: NextRequest): Promise { diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts index dc63d49e2..87f405604 100644 --- a/lib/research/getResearchFestivalsHandler.ts +++ b/lib/research/getResearchFestivalsHandler.ts @@ -8,7 +8,7 @@ import { validateGetResearchFestivalsRequest } from "@/lib/research/validateGetR * GET /api/research/festivals * * Returns a list of music festivals. Not artist-scoped — `/festival/list` is a - * global Chartmetric endpoint, so this uses `handleResearch`. + * global provider endpoint, so this uses `handleResearch`. * * @param request - The incoming HTTP request. * @returns The JSON response. diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts index 8b33474d2..fbbd04950 100644 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -8,8 +8,8 @@ import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/instagram-posts * - * Returns recent Instagram posts for the given artist via Chartmetric's - * DeepSocial integration. + * Returns recent Instagram posts for the given artist when the configured + * provider supports that dataset. * Requires `artist` query param. * * @param request - The incoming HTTP request. diff --git a/lib/research/getResearchLookupHandler.ts b/lib/research/getResearchLookupHandler.ts index 549e54859..026ef0a12 100644 --- a/lib/research/getResearchLookupHandler.ts +++ b/lib/research/getResearchLookupHandler.ts @@ -7,9 +7,9 @@ import { validateGetResearchLookupRequest } from "@/lib/research/validateGetRese /** * GET /api/research/lookup * - * Resolves a Spotify artist URL to Chartmetric IDs via the get-ids endpoint. + * Resolves a Spotify artist URL or ID to provider artist identifiers. * - * @param request - Requires `url` query param containing a Spotify artist URL + * @param request - Requires a Spotify artist URL or `spotifyId` query param. * @returns The JSON response. */ export async function getResearchLookupHandler(request: NextRequest): Promise { diff --git a/lib/research/getResearchMilestonesHandler.ts b/lib/research/getResearchMilestonesHandler.ts index 6bf5deefd..c5c523061 100644 --- a/lib/research/getResearchMilestonesHandler.ts +++ b/lib/research/getResearchMilestonesHandler.ts @@ -9,7 +9,7 @@ import { errorResponse } from "@/lib/networking/errorResponse"; * GET /api/research/milestones * * Returns an artist's activity feed — playlist adds, chart entries, and other - * notable events tracked by Chartmetric. + * notable events tracked by the configured research provider. * * @param request - The incoming HTTP request. * @returns The JSON response. diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts index 868039278..3b3140162 100644 --- a/lib/research/getResearchPlaylistHandler.ts +++ b/lib/research/getResearchPlaylistHandler.ts @@ -7,10 +7,7 @@ import { validateGetResearchPlaylistRequest } from "@/lib/research/validateGetRe /** * GET /api/research/playlist * - * Returns full Chartmetric playlist details for the supplied `platform` and - * `id`. This endpoint is a thin proxy over Chartmetric's - * `/playlist/:platform/:id`; discovery (search by name) is the caller's job - * via `GET /api/research?type=playlists&beta=true`. + * Returns provider playlist details for the supplied `platform` and `id`. * * @param request - query params: platform, id * @returns JSON playlist details or error diff --git a/lib/research/getResearchProfileHandler.ts b/lib/research/getResearchProfileHandler.ts index 9d142ddc5..f2dc2ea6c 100644 --- a/lib/research/getResearchProfileHandler.ts +++ b/lib/research/getResearchProfileHandler.ts @@ -8,7 +8,7 @@ import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/profile * - * Returns the full Chartmetric artist profile for the given artist. + * Returns the provider artist profile for the given artist. * Requires `artist` query param (name, numeric ID, or UUID). * * @param request - The incoming HTTP request. diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts index ef5ecb947..1914d316b 100644 --- a/lib/research/getResearchRankHandler.ts +++ b/lib/research/getResearchRankHandler.ts @@ -8,7 +8,7 @@ import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/rank * - * Returns the artist's global Chartmetric ranking. + * Returns the artist's global ranking when the configured provider supplies one. * * @param request - The incoming HTTP request. * @returns The JSON response. diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts index dad7499c3..335b6762b 100644 --- a/lib/research/getResearchSearchHandler.ts +++ b/lib/research/getResearchSearchHandler.ts @@ -7,7 +7,7 @@ import { validateGetResearchSearchRequest } from "@/lib/research/validateGetRese /** * GET /api/research/search * - * Searches Chartmetric for artists, tracks, or albums by name. + * Searches the configured research provider for artists, tracks, or labels by name. * * @param request - must include `q` query param * @returns JSON search results or error diff --git a/lib/research/getResearchTrackHandler.ts b/lib/research/getResearchTrackHandler.ts index b192fccdb..4dc0510e2 100644 --- a/lib/research/getResearchTrackHandler.ts +++ b/lib/research/getResearchTrackHandler.ts @@ -7,11 +7,10 @@ import { validateGetResearchTrackRequest } from "@/lib/research/validateGetResea /** * GET /api/research/track * - * Returns full Chartmetric track details for the supplied `id`. This endpoint - * is a thin proxy over Chartmetric's `/track/:id`; discovery (search by name, - * filter by artist) is the caller's job via `GET /api/research?type=tracks&beta=true`. + * Returns provider track details for the supplied `id`. Discovery (search by + * name, filter by artist) is the caller's job via `GET /api/research?type=tracks`. * - * @param request - must include numeric `id` query param + * @param request - must include `id` query param * @returns JSON track details or error */ export async function getResearchTrackHandler(request: NextRequest): Promise { diff --git a/lib/research/getResearchTrackPlaylistsHandler.ts b/lib/research/getResearchTrackPlaylistsHandler.ts index 2c00ab40d..2aed5f8a4 100644 --- a/lib/research/getResearchTrackPlaylistsHandler.ts +++ b/lib/research/getResearchTrackPlaylistsHandler.ts @@ -8,7 +8,7 @@ import { validateGetResearchTrackPlaylistsRequest } from "@/lib/research/validat /** * GET /api/research/track/playlists * - * Returns playlists featuring a specific track. Accepts a Chartmetric track ID + * Returns playlists featuring a specific track. Accepts a provider track ID * directly, or resolves via track name + optional artist. * * @param request - query params: id or q (+artist), platform, status, filter flags, pagination diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts index f5294321b..b815100a1 100644 --- a/lib/research/handleArtistResearch.ts +++ b/lib/research/handleArtistResearch.ts @@ -1,11 +1,12 @@ import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; export type HandleArtistResearchParams = { artist: string; + artistId?: string; accountId: string; - path: (cmId: number) => string; + path: (providerArtistId: string) => string; query?: Record; /** Credits to charge on success. Defaults to 5. */ credits?: number; @@ -14,8 +15,8 @@ export type HandleArtistResearchParams = { export type HandleArtistResearchResult = { data: unknown } | { error: string; status: number }; /** - * Resolves an artist to a Chartmetric ID, proxies to the built upstream path, - * and deducts credits on success. Credit-deduction failures are non-fatal. + * Resolves an artist to a provider artist ID, proxies to the built upstream + * path, and deducts credits on success. Credit-deduction failures are non-fatal. * * Credit gating (auto-recharge + 402 short-circuit) lives in route handlers * via `ensureCreditsOrShortCircuit` — see `handleResearch` for the rationale. @@ -25,12 +26,12 @@ export type HandleArtistResearchResult = { data: unknown } | { error: string; st export async function handleArtistResearch( params: HandleArtistResearchParams, ): Promise { - const { artist, accountId, path, query, credits = 5 } = params; + const { artist, artistId, accountId, path, query, credits = 5 } = params; - const resolved = await resolveArtist(artist); - if (resolved.error) return { error: resolved.error, status: 404 }; + const resolved = artistId ? { id: artistId } : await resolveArtist(artist); + if ("error" in resolved) return { error: resolved.error, status: 404 }; - const result = await fetchChartmetric(path(resolved.id), query); + const result = await fetchResearchProvider(path(resolved.id), query); if (result.status !== 200) { return { error: `Request failed with status ${result.status}`, status: result.status }; } diff --git a/lib/research/handleResearch.ts b/lib/research/handleResearch.ts index 8b283cccd..7cad3b2f0 100644 --- a/lib/research/handleResearch.ts +++ b/lib/research/handleResearch.ts @@ -1,4 +1,4 @@ -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; export type HandleResearchParams = { @@ -12,9 +12,9 @@ export type HandleResearchParams = { export type HandleResearchResult = { data: unknown } | { error: string; status: number }; /** - * Proxies a non-artist-scoped research call to Chartmetric and deducts credits - * on success. Credit-deduction failures are non-fatal — the fetched data is - * still returned so transient billing failures don't block read endpoints. + * Proxies a non-artist-scoped research call to the configured research data + * provider and deducts credits on success. Credit-deduction failures are + * non-fatal so transient billing failures don't block read endpoints. * * Credit gating (auto-recharge + 402 short-circuit) lives in route handlers * via `ensureCreditsOrShortCircuit` — keeping this helper free of NextResponse @@ -25,7 +25,7 @@ export type HandleResearchResult = { data: unknown } | { error: string; status: export async function handleResearch(params: HandleResearchParams): Promise { const { accountId, path, query, credits = 5 } = params; - const result = await fetchChartmetric(path, query); + const result = await fetchResearchProvider(path, query); if (result.status !== 200) { return { error: `Request failed with status ${result.status}`, status: result.status }; } diff --git a/lib/research/providers/ResearchProvider.ts b/lib/research/providers/ResearchProvider.ts new file mode 100644 index 000000000..5d55660d8 --- /dev/null +++ b/lib/research/providers/ResearchProvider.ts @@ -0,0 +1 @@ +export type ResearchProvider = "songstats" | "chartmetric"; diff --git a/lib/research/providers/__tests__/fetchResearchProvider.test.ts b/lib/research/providers/__tests__/fetchResearchProvider.test.ts new file mode 100644 index 000000000..fb305b17f --- /dev/null +++ b/lib/research/providers/__tests__/fetchResearchProvider.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchResearchProvider } from "../fetchResearchProvider"; +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; + +vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ + fetchChartmetric: vi.fn(), +})); + +vi.mock("@/lib/research/songstats/fetchSongstatsResearch", () => ({ + fetchSongstatsResearch: vi.fn(), +})); + +const ORIGINAL_PROVIDER = process.env.RESEARCH_PROVIDER; + +describe("fetchResearchProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env.RESEARCH_PROVIDER = ORIGINAL_PROVIDER; + }); + + it("uses SongStats research by default", async () => { + delete process.env.RESEARCH_PROVIDER; + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: { ok: true } }); + + const result = await fetchResearchProvider("/search", { q: "Drake" }); + + expect(result).toEqual({ status: 200, data: { ok: true } }); + expect(fetchSongstatsResearch).toHaveBeenCalledWith("/search", { q: "Drake" }); + expect(fetchChartmetric).not.toHaveBeenCalled(); + }); + + it("uses Chartmetric when configured as the legacy provider", async () => { + process.env.RESEARCH_PROVIDER = "chartmetric"; + vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: { legacy: true } }); + + const result = await fetchResearchProvider("/search", { q: "Drake" }); + + expect(result).toEqual({ status: 200, data: { legacy: true } }); + expect(fetchChartmetric).toHaveBeenCalledWith("/search", { q: "Drake" }); + expect(fetchSongstatsResearch).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/research/providers/__tests__/getResearchProvider.test.ts b/lib/research/providers/__tests__/getResearchProvider.test.ts new file mode 100644 index 000000000..7e8d3d449 --- /dev/null +++ b/lib/research/providers/__tests__/getResearchProvider.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, afterEach } from "vitest"; + +import { getResearchProvider } from "../getResearchProvider"; + +const ORIGINAL_PROVIDER = process.env.RESEARCH_PROVIDER; + +describe("getResearchProvider", () => { + afterEach(() => { + process.env.RESEARCH_PROVIDER = ORIGINAL_PROVIDER; + }); + + it("defaults research to SongStats", () => { + delete process.env.RESEARCH_PROVIDER; + + expect(getResearchProvider()).toBe("songstats"); + }); + + it("preserves Chartmetric when explicitly configured as the legacy provider", () => { + process.env.RESEARCH_PROVIDER = "chartmetric"; + + expect(getResearchProvider()).toBe("chartmetric"); + }); + + it("falls back to SongStats for unrecognized provider values", () => { + process.env.RESEARCH_PROVIDER = "unknown"; + + expect(getResearchProvider()).toBe("songstats"); + }); +}); diff --git a/lib/research/providers/fetchResearchProvider.ts b/lib/research/providers/fetchResearchProvider.ts new file mode 100644 index 000000000..76626ee85 --- /dev/null +++ b/lib/research/providers/fetchResearchProvider.ts @@ -0,0 +1,19 @@ +import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { getResearchProvider } from "@/lib/research/providers/getResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; + +interface ProxyResult { + data: unknown; + status: number; +} + +export async function fetchResearchProvider( + path: string, + queryParams?: Record, +): Promise { + if (getResearchProvider() === "chartmetric") { + return fetchChartmetric(path, queryParams); + } + + return fetchSongstatsResearch(path, queryParams); +} diff --git a/lib/research/providers/getResearchProvider.ts b/lib/research/providers/getResearchProvider.ts new file mode 100644 index 000000000..8a93416fc --- /dev/null +++ b/lib/research/providers/getResearchProvider.ts @@ -0,0 +1,7 @@ +import type { ResearchProvider } from "@/lib/research/providers/ResearchProvider"; + +export function getResearchProvider(): ResearchProvider { + const provider = process.env.RESEARCH_PROVIDER?.trim().toLowerCase(); + if (provider === "chartmetric") return "chartmetric"; + return "songstats"; +} diff --git a/lib/research/resolveArtist.ts b/lib/research/resolveArtist.ts index 17b57a69c..b0f2fa675 100644 --- a/lib/research/resolveArtist.ts +++ b/lib/research/resolveArtist.ts @@ -1,20 +1,21 @@ -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; 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. + * Resolves an artist identifier (name, UUID, or legacy numeric provider ID) to + * a provider artist ID. * - * - Numeric string → used directly as Chartmetric ID + * - Legacy numeric string → used directly as provider ID * - UUID → future: look up mapping. For now, returns error. - * - String → searches Chartmetric by name, returns top match ID + * - String → searches the configured provider 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 + * @returns The provider artist ID, or null if not found */ export async function resolveArtist( artist: string, -): Promise<{ id: number; error?: never } | { id?: never; error: string }> { +): Promise<{ id: string; error?: never } | { id?: never; error: string }> { if (!artist || !artist.trim()) { return { error: "artist parameter is required" }; } @@ -22,17 +23,17 @@ export async function resolveArtist( const trimmed = artist.trim(); if (/^\d+$/.test(trimmed)) { - return { id: parseInt(trimmed, 10) }; + return { id: trimmed }; } if (UUID_REGEX.test(trimmed)) { - // TODO: Look up Recoup artist ID → Chartmetric ID mapping in database + // TODO: Look up Recoup artist ID → provider artist ID mapping in database return { error: "Recoup artist ID resolution is not yet implemented. Use an artist name instead.", }; } - const result = await fetchChartmetric("/search", { + const result = await fetchResearchProvider("/search", { q: trimmed, type: "artists", limit: "1", @@ -42,12 +43,19 @@ export async function resolveArtist( return { error: `Search failed with status ${result.status}` }; } - const data = result.data as { artists?: Array<{ id: number; name: string }> }; + const data = result.data as { + artists?: Array<{ id?: string | number; songstats_artist_id?: string | number; name: string }>; + }; const artists = data?.artists; if (!artists || artists.length === 0) { return { error: `No artist found matching "${trimmed}"` }; } - return { id: artists[0].id }; + const id = artists[0].id ?? artists[0].songstats_artist_id; + if (id === undefined || id === null || id === "") { + return { error: `No provider artist ID found for "${trimmed}"` }; + } + + return { id: String(id) }; } diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index de2ff44c5..154cc5a56 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -4,16 +4,30 @@ import { handleResearch } from "@/lib/research/handleResearch"; interface GetIdsResponse { chartmetric_ids?: number[]; + songstats_track_ids?: string[]; + songstats_track_id?: string; + id?: string | number; +} + +function extractProviderTrackId(data: unknown): string | undefined { + const ids = (Array.isArray(data) ? data[0] : data) as GetIdsResponse | undefined; + const id = + ids?.songstats_track_ids?.[0] ?? + ids?.songstats_track_id ?? + ids?.chartmetric_ids?.[0] ?? + ids?.id; + + return id === undefined || id === null || id === "" ? undefined : String(id); } /** - * Resolves a track name (+ optional artist) to a Chartmetric track ID. + * Resolves a track name (+ optional artist) to a provider track ID. * * Uses Spotify search for accurate matching, gets the ISRC, then maps - * to a Chartmetric ID via /track/isrc/{isrc}/get-ids. + * to a provider ID via /track/isrc/{isrc}/get-ids. * Works across all platforms since ISRC is a universal identifier. * - * Chartmetric calls are routed through {@link handleResearch} so each + * Provider calls are routed through {@link handleResearch} so each * lookup properly deducts credits from the caller's account. */ export async function resolveTrack( @@ -59,9 +73,8 @@ export async function resolveTrack( path: `/track/isrc/${isrc}/get-ids`, }); if ("data" in result) { - const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; - const cmId = ids?.chartmetric_ids?.[0]; - if (cmId) return { id: String(cmId) }; + const providerId = extractProviderTrackId(result.data); + if (providerId) return { id: providerId }; } } @@ -71,10 +84,9 @@ export async function resolveTrack( path: `/track/spotify/${spotifyId}/get-ids`, }); if ("data" in result) { - const ids = (Array.isArray(result.data) ? result.data[0] : result.data) as GetIdsResponse; - const cmId = ids?.chartmetric_ids?.[0]; - if (cmId) return { id: String(cmId) }; + const providerId = extractProviderTrackId(result.data); + if (providerId) return { id: providerId }; } - return { error: `Could not resolve Chartmetric ID for "${spotifyTrack.name}"` }; + return { error: `Could not resolve provider track ID for "${spotifyTrack.name}"` }; } diff --git a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts new file mode 100644 index 000000000..51e292874 --- /dev/null +++ b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchSongstatsResearch } from "../fetchSongstatsResearch"; +import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; + +vi.mock("@/lib/songstats/fetchSongstats", () => ({ + fetchSongstats: vi.fn(), +})); + +describe("fetchSongstatsResearch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps artist searches to SongStats artist search and keeps the public results family", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ + status: 200, + data: { results: [{ songstats_artist_id: "artist_1", name: "Drake" }] }, + }); + + const result = await fetchSongstatsResearch("/search", { + q: "Drake", + type: "artists", + limit: "1", + beta: "true", + }); + + expect(fetchSongstats).toHaveBeenCalledWith("/artists/search", { + q: "Drake", + limit: "1", + }); + expect(result).toEqual({ + status: 200, + data: { artists: [{ id: "artist_1", songstats_artist_id: "artist_1", name: "Drake" }] }, + }); + }); + + it("maps track detail requests to SongStats track info", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ + status: 200, + data: { songstats_track_id: "track_1", title: "Hotline Bling" }, + }); + + const result = await fetchSongstatsResearch("/track/track_1"); + + expect(fetchSongstats).toHaveBeenCalledWith("/tracks/info", { + songstats_track_id: "track_1", + }); + expect(result).toEqual({ + status: 200, + data: { id: "track_1", songstats_track_id: "track_1", title: "Hotline Bling" }, + }); + }); + + it("maps Spotify artist lookup to SongStats artist info and exposes a provider-neutral id", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ + status: 200, + data: { songstats_artist_id: "artist_1", spotify_artist_id: "spotify_1" }, + }); + + const result = await fetchSongstatsResearch("/artist/spotify/spotify_1/get-ids"); + + expect(fetchSongstats).toHaveBeenCalledWith("/artists/info", { + spotify_artist_id: "spotify_1", + }); + expect(result).toEqual({ + status: 200, + data: { + id: "artist_1", + songstats_artist_id: "artist_1", + spotify_artist_id: "spotify_1", + }, + }); + }); + + it("returns an explicit unsupported result for Chartmetric-only paths", async () => { + const result = await fetchSongstatsResearch("/curator/spotify/2"); + + expect(result).toEqual({ + status: 501, + data: { error: "Research data source does not support this endpoint" }, + }); + expect(fetchSongstats).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/research/songstats/fetchSongstatsResearch.ts b/lib/research/songstats/fetchSongstatsResearch.ts new file mode 100644 index 000000000..85868fc3d --- /dev/null +++ b/lib/research/songstats/fetchSongstatsResearch.ts @@ -0,0 +1,314 @@ +import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; + +interface ProxyResult { + data: unknown; + status: number; +} + +type JsonRecord = Record; + +const UNSUPPORTED_RESULT: ProxyResult = { + status: 501, + data: { error: "Research data source does not support this endpoint" }, +}; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function firstRecord(value: unknown): JsonRecord | null { + if (Array.isArray(value)) return isRecord(value[0]) ? value[0] : null; + return isRecord(value) ? value : null; +} + +function extractList(value: unknown, keys: string[]): unknown[] { + if (Array.isArray(value)) return value; + if (!isRecord(value)) return []; + + for (const key of keys) { + const child = value[key]; + if (Array.isArray(child)) return child; + if (isRecord(child)) { + const nested = extractList(child, keys); + if (nested.length) return nested; + } + } + + return []; +} + +function pickString(record: JsonRecord, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" || typeof value === "number") return String(value); + } + + return undefined; +} + +function normalizeArtistRecord(value: unknown): unknown { + if (!isRecord(value)) return value; + + const id = pickString(value, ["songstats_artist_id", "artist_id", "id"]); + return id ? { ...value, id } : value; +} + +function normalizeTrackRecord(value: unknown): unknown { + if (!isRecord(value)) return value; + + const id = pickString(value, ["songstats_track_id", "track_id", "id"]); + return id ? { ...value, id } : value; +} + +function normalizeArtistObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + return normalizeArtistRecord(record); +} + +function normalizeTrackObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + + const id = pickString(record, ["songstats_track_id", "track_id", "id"]); + return id ? { ...record, id } : record; +} + +function normalizeTrackLookupObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + + const id = pickString(record, ["songstats_track_id", "track_id", "id"]); + if (!id) return record; + + return { + ...record, + id, + songstats_track_ids: [id], + }; +} + +function normalizeUrlMap(value: unknown): JsonRecord { + const urls: JsonRecord = {}; + + const visit = (current: unknown, keyHint?: string): void => { + if (typeof current === "string") { + if (/^https?:\/\//i.test(current)) urls[keyHint || current] = current; + return; + } + + if (Array.isArray(current)) { + for (const item of current) visit(item, keyHint); + return; + } + + if (!isRecord(current)) return; + + const platform = pickString(current, ["platform", "source", "type", "name", "domain"]); + const url = pickString(current, ["url", "link", "href"]); + if (url && /^https?:\/\//i.test(url)) { + urls[platform || url] = url; + } + + for (const [key, child] of Object.entries(current)) { + visit(child, key); + } + }; + + visit(value); + return urls; +} + +async function mapSongstatsResult( + endpoint: string, + query?: Record, + normalize?: (value: unknown) => unknown, +): Promise { + const result = await fetchSongstats(endpoint, query); + if (result.status !== 200 || !normalize) return result; + return { status: result.status, data: normalize(result.data) }; +} + +function withoutLegacySearchParams(query?: Record): Record { + return { + q: query?.q || "", + ...(query?.limit ? { limit: query.limit } : {}), + ...(query?.offset ? { offset: query.offset } : {}), + }; +} + +function mapEntitySearch( + path: string, + query?: Record, +): Promise | null { + if (path !== "/search") return null; + + const type = (query?.type || "artists").toLowerCase(); + if (type === "artists" || type === "artist") { + return mapSongstatsResult("/artists/search", withoutLegacySearchParams(query), data => ({ + artists: extractList(data, ["artists", "results", "data", "items"]).map( + normalizeArtistRecord, + ), + })); + } + + if (type === "tracks" || type === "track") { + return mapSongstatsResult("/tracks/search", withoutLegacySearchParams(query), data => ({ + tracks: extractList(data, ["tracks", "results", "data", "items"]).map(normalizeTrackRecord), + })); + } + + if (type === "labels" || type === "label") { + return mapSongstatsResult("/labels/search", withoutLegacySearchParams(query), data => ({ + labels: extractList(data, ["labels", "results", "data", "items"]), + })); + } + + return Promise.resolve(UNSUPPORTED_RESULT); +} + +function mapArtistPath(path: string, query?: Record): Promise | null { + let match = path.match(/^\/artist\/spotify\/([^/]+)\/get-ids$/); + if (match) { + return mapSongstatsResult( + "/artists/info", + { spotify_artist_id: match[1] }, + normalizeArtistObject, + ); + } + + match = path.match(/^\/artist\/([^/]+)$/); + if (match) { + return mapSongstatsResult( + "/artists/info", + { songstats_artist_id: match[1] }, + normalizeArtistObject, + ); + } + + match = path.match(/^\/artist\/([^/]+)\/albums$/); + if (match) { + return mapSongstatsResult( + "/artists/catalog", + { songstats_artist_id: match[1], ...query }, + data => extractList(data, ["albums", "catalog", "tracks", "results", "data", "items"]), + ); + } + + match = path.match(/^\/artist\/([^/]+)\/tracks$/); + if (match) { + return mapSongstatsResult( + "/artists/catalog", + { songstats_artist_id: match[1], ...query }, + data => extractList(data, ["tracks", "catalog", "results", "data", "items"]), + ); + } + + match = path.match(/^\/artist\/([^/]+)\/stat\/([^/]+)$/); + if (match) { + return mapSongstatsResult("/artists/stats", { + songstats_artist_id: match[1], + source: match[2], + ...query, + }); + } + + match = path.match(/^\/artist\/([^/]+)\/([^/]+)-audience-stats$/); + if (match) { + return mapSongstatsResult("/artists/audience", { + songstats_artist_id: match[1], + source: match[2], + ...query, + }); + } + + match = path.match(/^\/artist\/([^/]+)\/(career|milestones|noteworthy-insights)$/); + if (match) { + return mapSongstatsResult( + "/artists/activities", + { songstats_artist_id: match[1], ...query }, + data => extractList(data, ["activities", "results", "data", "items"]), + ); + } + + match = path.match(/^\/artist\/([^/]+)\/urls$/); + if (match) { + return mapSongstatsResult("/artists/info", { songstats_artist_id: match[1] }, normalizeUrlMap); + } + + match = path.match(/^\/artist\/([^/]+)\/artist-rank$/); + if (match) { + return mapSongstatsResult("/artists/stats", { songstats_artist_id: match[1], ...query }); + } + + match = path.match(/^\/artist\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); + if (match) { + return mapSongstatsResult( + "/artists/top_playlists", + { + songstats_artist_id: match[1], + source: match[2], + status: match[3], + ...query, + }, + data => extractList(data, ["playlists", "results", "data", "items"]), + ); + } + + return null; +} + +function mapTrackPath(path: string, query?: Record): Promise | null { + let match = path.match(/^\/track\/isrc\/([^/]+)\/get-ids$/); + if (match) { + return mapSongstatsResult("/tracks/info", { isrc: match[1] }, normalizeTrackLookupObject); + } + + match = path.match(/^\/track\/spotify\/([^/]+)\/get-ids$/); + if (match) { + return mapSongstatsResult( + "/tracks/info", + { spotify_track_id: match[1] }, + normalizeTrackLookupObject, + ); + } + + match = path.match(/^\/track\/([^/]+)$/); + if (match) { + return mapSongstatsResult( + "/tracks/info", + { songstats_track_id: match[1] }, + normalizeTrackObject, + ); + } + + match = path.match(/^\/track\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); + if (match) { + return mapSongstatsResult( + "/tracks/activities", + { + songstats_track_id: match[1], + source: match[2], + status: match[3], + ...query, + }, + data => extractList(data, ["playlists", "activities", "results", "data", "items"]), + ); + } + + return null; +} + +export async function fetchSongstatsResearch( + path: string, + query?: Record, +): Promise { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + + return ( + (await mapEntitySearch(normalizedPath, query)) || + (await mapArtistPath(normalizedPath, query)) || + (await mapTrackPath(normalizedPath, query)) || + UNSUPPORTED_RESULT + ); +} diff --git a/lib/research/validateArtistRequest.ts b/lib/research/validateArtistRequest.ts index 7a09857d6..9d88fc10d 100644 --- a/lib/research/validateArtistRequest.ts +++ b/lib/research/validateArtistRequest.ts @@ -4,26 +4,33 @@ import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; import { errorResponse } from "@/lib/networking/errorResponse"; /** - * Auth + `artist` query param gate for artist-scoped research endpoints. + * Auth + artist identifier query param gate for artist-scoped research endpoints. * Also ensures the account has enough credits to cover the call (auto-recharging * via a saved card if short, returning a 402 NextResponse otherwise). * * Returns a `NextResponse` to short-circuit on auth, schema, or credit failures. * Otherwise returns the authenticated `accountId` and the `artist` query value. + * `id` is accepted as a provider-neutral alias when callers already resolved + * the artist through `/api/research/lookup`. * * @param request - The incoming HTTP request. */ export async function validateArtistRequest( request: NextRequest, -): Promise { +): Promise { const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; const artist = new URL(request.url).searchParams.get("artist"); - if (!artist) return errorResponse("artist parameter is required", 400); + const artistId = new URL(request.url).searchParams.get("id") ?? undefined; + if (!artist && !artistId) return errorResponse("artist parameter is required", 400); const short = await ensureResearchCredits(authResult.accountId); if (short) return short; - return { accountId: authResult.accountId, artist }; + if (artistId) { + return { accountId: authResult.accountId, artist: artistId, artistId }; + } + + return { accountId: authResult.accountId, artist: artist as string }; } diff --git a/lib/research/validateGetResearchAlbumsRequest.ts b/lib/research/validateGetResearchAlbumsRequest.ts index 67b37714b..4d5ec1356 100644 --- a/lib/research/validateGetResearchAlbumsRequest.ts +++ b/lib/research/validateGetResearchAlbumsRequest.ts @@ -12,13 +12,12 @@ export type ValidatedGetResearchAlbumsRequest = { }; const VALID_BOOLEAN = ["true", "false"] as const; +const PROVIDER_ID_REGEX = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; /** - * Validates `GET /api/research/albums` — auth + required numeric `artist_id` - * (Chartmetric artist ID). Optional `is_primary` (defaults to `"true"`) maps - * to Chartmetric's `isPrimary` filter, which when true returns only albums - * where the artist is a main artist — excluding DJ compilations, soundtracks, - * and feature appearances. Optional `limit` and `offset` for pagination. + * Validates `GET /api/research/albums` — auth + required provider `artist_id`. + * Optional `is_primary` (defaults to `"true"`) maps to provider support for + * primary releases when available. Optional `limit` and `offset` paginate. * * @param request - The incoming HTTP request. */ @@ -31,8 +30,8 @@ export async function validateGetResearchAlbumsRequest( const { searchParams } = new URL(request.url); const artistId = searchParams.get("artist_id"); if (!artistId) return errorResponse("artist_id parameter is required", 400); - if (!/^[1-9]\d*$/.test(artistId)) - return errorResponse("artist_id must be a positive integer", 400); + if (!PROVIDER_ID_REGEX.test(artistId)) + return errorResponse("artist_id must be a provider artist ID", 400); const isPrimary = searchParams.get("is_primary") ?? "true"; if (!(VALID_BOOLEAN as readonly string[]).includes(isPrimary)) { diff --git a/lib/research/validateGetResearchChartsRequest.ts b/lib/research/validateGetResearchChartsRequest.ts index 9ec2f9729..070bbe936 100644 --- a/lib/research/validateGetResearchChartsRequest.ts +++ b/lib/research/validateGetResearchChartsRequest.ts @@ -21,7 +21,7 @@ export type ValidatedGetResearchChartsRequest = { * alpha) + defaults for `country` ("US"), `interval` ("daily"), `type` * ("regional"), and `latest` ("true"). `interval`, `type`, and `latest` are * rejected at this layer if they aren't in the documented enum — this turns - * an opaque upstream Chartmetric 400 into a specific 400 from us. + * an opaque upstream provider 400 into a specific 400 from us. * * @param request - The incoming HTTP request. */ diff --git a/lib/research/validateGetResearchCuratorRequest.ts b/lib/research/validateGetResearchCuratorRequest.ts index 8b7e0a00d..b949911e8 100644 --- a/lib/research/validateGetResearchCuratorRequest.ts +++ b/lib/research/validateGetResearchCuratorRequest.ts @@ -14,7 +14,7 @@ export type ValidatedGetResearchCuratorRequest = { /** * Validates `GET /api/research/curator` — auth + required `platform` (enum) - * and `id` (numeric Chartmetric curator ID). + * and `id` (numeric curator ID). * * @param request - The incoming HTTP request. */ @@ -36,7 +36,7 @@ export async function validateGetResearchCuratorRequest( } if (!/^\d+$/.test(id)) { - return errorResponse("id must be a numeric Chartmetric curator ID (e.g. 2 for Spotify)", 400); + return errorResponse("id must be a numeric curator ID (e.g. 2 for Spotify)", 400); } const short = await ensureResearchCredits(authResult.accountId); diff --git a/lib/research/validateGetResearchLookupRequest.ts b/lib/research/validateGetResearchLookupRequest.ts index a60bed982..8a3ea62e6 100644 --- a/lib/research/validateGetResearchLookupRequest.ts +++ b/lib/research/validateGetResearchLookupRequest.ts @@ -4,6 +4,7 @@ import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; import { errorResponse } from "@/lib/networking/errorResponse"; const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([a-zA-Z0-9]+)/; +const SPOTIFY_ID_REGEX = /^[A-Za-z0-9]{10,}$/; export type ValidatedGetResearchLookupRequest = { accountId: string; @@ -11,8 +12,8 @@ export type ValidatedGetResearchLookupRequest = { }; /** - * Validates `GET /api/research/lookup` — auth + `url` (required Spotify artist - * URL). Extracts the Spotify artist ID from the URL. + * Validates `GET /api/research/lookup` — auth plus a Spotify artist URL or ID. + * Extracts or passes through the Spotify artist ID. * * @param request - The incoming HTTP request. */ @@ -23,6 +24,18 @@ export async function validateGetResearchLookupRequest( if (authResult instanceof NextResponse) return authResult; const { searchParams } = new URL(request.url); + const directSpotifyId = searchParams.get("spotifyId"); + if (directSpotifyId) { + if (!SPOTIFY_ID_REGEX.test(directSpotifyId)) { + return errorResponse("spotifyId must be a valid Spotify artist ID", 400); + } + + const short = await ensureResearchCredits(authResult.accountId); + if (short) return short; + + return { accountId: authResult.accountId, spotifyId: directSpotifyId }; + } + const url = searchParams.get("url"); if (!url) return errorResponse("url parameter is required", 400); diff --git a/lib/research/validateGetResearchSearchRequest.ts b/lib/research/validateGetResearchSearchRequest.ts index c25639926..e9b3cb57c 100644 --- a/lib/research/validateGetResearchSearchRequest.ts +++ b/lib/research/validateGetResearchSearchRequest.ts @@ -16,9 +16,8 @@ export type ValidatedGetResearchSearchRequest = { /** * Validates `GET /api/research/search` — auth + required `q` query param, with * defaults for `type` ("artists") and `limit` ("10"). Also accepts the optional - * Chartmetric pass-throughs: `beta` (enables the improved search engine), - * `platforms` (comma-separated string, beta-only per Chartmetric docs), and - * `offset` (numeric string for paging). + * legacy provider pass-throughs: `beta`, `platforms` (comma-separated string), + * and `offset` (numeric string for paging). * * @param request - The incoming HTTP request. */ diff --git a/lib/research/validateGetResearchTrackRequest.ts b/lib/research/validateGetResearchTrackRequest.ts index c957305d9..5c694497c 100644 --- a/lib/research/validateGetResearchTrackRequest.ts +++ b/lib/research/validateGetResearchTrackRequest.ts @@ -8,11 +8,12 @@ export type ValidatedGetResearchTrackRequest = { id: string; }; +const PROVIDER_ID_REGEX = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + /** - * Validates `GET /api/research/track` — auth + required numeric `id` (the - * Chartmetric track ID). Discovery (search by name, filter by artist) is the - * caller's job via `GET /api/research?type=tracks&beta=true`; this endpoint - * is a thin detail-lookup proxy. + * Validates `GET /api/research/track` — auth + required provider track `id`. + * Discovery (search by name, filter by artist) is the caller's job via + * `GET /api/research?type=tracks`; this endpoint is a thin detail lookup. * * @param request - The incoming HTTP request. */ @@ -25,7 +26,7 @@ export async function validateGetResearchTrackRequest( const { searchParams } = new URL(request.url); const id = searchParams.get("id"); if (!id) return errorResponse("id parameter is required", 400); - if (!/^[1-9]\d*$/.test(id)) return errorResponse("id must be a positive integer", 400); + if (!PROVIDER_ID_REGEX.test(id)) return errorResponse("id must be a provider track ID", 400); const short = await ensureResearchCredits(authResult.accountId); if (short) return short; diff --git a/lib/songstats/__tests__/fetchSongstats.test.ts b/lib/songstats/__tests__/fetchSongstats.test.ts new file mode 100644 index 000000000..dde0ef917 --- /dev/null +++ b/lib/songstats/__tests__/fetchSongstats.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchSongstats } from "../fetchSongstats"; + +const ORIGINAL_ENV = process.env; + +describe("fetchSongstats", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV, SongStats_API: "songstats-key" }; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + vi.unstubAllGlobals(); + }); + + it("sends requests to the Enterprise API with the SongStats apikey header", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ results: [{ id: "artist_1" }] }), + headers: new Headers({ "content-type": "application/json" }), + } as Response); + + const result = await fetchSongstats("/artists/search", { q: "Drake", limit: "1" }); + + expect(result).toEqual({ status: 200, data: { results: [{ id: "artist_1" }] } }); + expect(fetch).toHaveBeenCalledWith( + "https://data.songstats.com/enterprise/v1/artists/search?q=Drake&limit=1", + { + method: "GET", + headers: { + accept: "application/json", + apikey: "songstats-key", + }, + }, + ); + }); + + it("returns a 500-compatible result when SongStats_API is not configured", async () => { + delete process.env.SongStats_API; + + const result = await fetchSongstats("/artists/search", { q: "Drake" }); + + expect(result.status).toBe(500); + expect(result.data).toEqual({ error: "SongStats_API environment variable is not set" }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("passes upstream error statuses through without throwing", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: "forbidden" }), + headers: new Headers({ "content-type": "application/json" }), + } as Response); + + const result = await fetchSongstats("/artists/info", { songstats_artist_id: "artist_1" }); + + expect(result).toEqual({ status: 403, data: { error: "forbidden" } }); + }); +}); diff --git a/lib/songstats/fetchSongstats.ts b/lib/songstats/fetchSongstats.ts new file mode 100644 index 000000000..0fb178b58 --- /dev/null +++ b/lib/songstats/fetchSongstats.ts @@ -0,0 +1,55 @@ +import { SONGSTATS_BASE } from "@/lib/songstats/songstatsBase"; + +interface ProxyResult { + data: unknown; + status: number; +} + +function appendQueryParams(url: URL, queryParams?: Record): void { + if (!queryParams) return; + + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== "") { + url.searchParams.set(key, value); + } + } +} + +async function parseSongstatsResponse(response: Response): Promise { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) return response.json(); + + const text = await response.text(); + return text ? { raw: text } : null; +} + +export async function fetchSongstats( + path: string, + queryParams?: Record, +): Promise { + const apiKey = process.env.SongStats_API; + if (!apiKey) { + return { + data: { error: "SongStats_API environment variable is not set" }, + status: 500, + }; + } + + try { + const url = new URL(`${SONGSTATS_BASE}/enterprise/v1/${path.replace(/^\/+/, "")}`); + appendQueryParams(url, queryParams); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + accept: "application/json", + apikey: apiKey, + }, + }); + + const data = await parseSongstatsResponse(response); + return { data, status: response.status }; + } catch { + return { data: null, status: 500 }; + } +} diff --git a/lib/songstats/songstatsBase.ts b/lib/songstats/songstatsBase.ts new file mode 100644 index 000000000..48cad66b6 --- /dev/null +++ b/lib/songstats/songstatsBase.ts @@ -0,0 +1,2 @@ +/** Base URL for the SongStats Enterprise API. */ +export const SONGSTATS_BASE = "https://data.songstats.com"; From 517454454fccf63c11d44d026aca8e964c194322 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:16:39 -0400 Subject: [PATCH 02/21] fix: address SongStats migration review feedback --- app/api/research/albums/route.ts | 2 +- app/api/research/track/route.ts | 2 +- lib/chartmetric/fetchChartmetric.ts | 6 +- .../getResearchSearchHandler.test.ts | 12 + .../__tests__/validateArtistRequest.test.ts | 15 +- .../validateGetResearchLookupRequest.test.ts | 22 ++ .../validateGetResearchMetricsRequest.test.ts | 2 +- lib/research/getResearchCuratorHandler.ts | 5 +- .../getResearchInstagramPostsHandler.ts | 4 +- lib/research/getResearchSearchHandler.ts | 4 +- lib/research/providerId.ts | 1 + lib/research/providers/ProxyResult.ts | 4 + .../providers/fetchResearchProvider.ts | 6 +- .../songstats/fetchSongstatsResearch.ts | 285 +----------------- .../songstats/mapSongstatsArtistPath.ts | 99 ++++++ .../songstats/mapSongstatsTrackPath.ts | 51 ++++ .../songstats/songstatsResearchMapping.ts | 134 ++++++++ lib/research/validateArtistRequest.ts | 11 +- .../validateGetResearchAlbumsRequest.ts | 2 +- .../validateGetResearchCuratorRequest.ts | 2 +- .../validateGetResearchLookupRequest.ts | 4 +- .../validateGetResearchTrackRequest.ts | 3 +- .../__tests__/fetchSongstats.test.ts | 42 ++- lib/songstats/fetchSongstats.ts | 53 +++- 24 files changed, 453 insertions(+), 318 deletions(-) create mode 100644 lib/research/providerId.ts create mode 100644 lib/research/providers/ProxyResult.ts create mode 100644 lib/research/songstats/mapSongstatsArtistPath.ts create mode 100644 lib/research/songstats/mapSongstatsTrackPath.ts create mode 100644 lib/research/songstats/songstatsResearchMapping.ts diff --git a/app/api/research/albums/route.ts b/app/api/research/albums/route.ts index 0c9d941b5..19537f24a 100644 --- a/app/api/research/albums/route.ts +++ b/app/api/research/albums/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { * GET /api/research/albums — Album discography for a provider artist id. * Discovery by name is the caller's job via `GET /api/research`. * - * @param request - must include numeric `artist_id` query param + * @param request - must include provider `artist_id` query param * @returns JSON album list or error */ export async function GET(request: NextRequest) { diff --git a/app/api/research/track/route.ts b/app/api/research/track/route.ts index a2af64b51..5cebe1a39 100644 --- a/app/api/research/track/route.ts +++ b/app/api/research/track/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { * GET /api/research/track — Full provider track details by `id`. * Discovery (search by name) is the caller's job via `GET /api/research`. * - * @param request - must include numeric `id` query param + * @param request - must include provider `id` query param * @returns JSON track details or error */ export async function GET(request: NextRequest) { diff --git a/lib/chartmetric/fetchChartmetric.ts b/lib/chartmetric/fetchChartmetric.ts index ec04abcd1..42e6eab50 100644 --- a/lib/chartmetric/fetchChartmetric.ts +++ b/lib/chartmetric/fetchChartmetric.ts @@ -1,10 +1,6 @@ import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; import { CHARTMETRIC_BASE } from "@/lib/chartmetric/chartmetricBase"; - -interface ProxyResult { - data: unknown; - status: number; -} +import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; /** * Proxies a request to the Chartmetric API with authentication. diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts index aeb37ec30..ab0e1302a 100644 --- a/lib/research/__tests__/getResearchSearchHandler.test.ts +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -119,4 +119,16 @@ describe("getResearchSearchHandler", () => { expect(res.status).toBe(200); expect(body.results[0]).toMatchObject({ name: "Drake", target: "artists" }); }); + + it("returns labels when the provider returns a labels array", async () => { + vi.mocked(handleResearch).mockResolvedValue({ + data: { labels: [{ name: "OVO Sound", id: "label_1" }] }, + }); + const req = new NextRequest("http://localhost/api/research/search?q=OVO&type=labels"); + const res = await getResearchSearchHandler(req); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.results).toEqual([{ name: "OVO Sound", id: "label_1" }]); + }); }); diff --git a/lib/research/__tests__/validateArtistRequest.test.ts b/lib/research/__tests__/validateArtistRequest.test.ts index ea7d4b98b..44ea2615c 100644 --- a/lib/research/__tests__/validateArtistRequest.test.ts +++ b/lib/research/__tests__/validateArtistRequest.test.ts @@ -35,7 +35,7 @@ describe("validateArtistRequest", () => { if (result instanceof NextResponse) { expect(result.status).toBe(400); const body = await result.json(); - expect(body.error).toBe("artist parameter is required"); + expect(body.error).toBe("artist or id parameter is required"); } }); @@ -58,4 +58,17 @@ describe("validateArtistRequest", () => { artistId: "artist_123", }); }); + + it("returns a 400 response when id is not a provider artist identifier", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + + const result = await validateArtistRequest(new NextRequest("http://x/?id=../artist")); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toBe("id must be a provider artist ID"); + } + }); }); diff --git a/lib/research/__tests__/validateGetResearchLookupRequest.test.ts b/lib/research/__tests__/validateGetResearchLookupRequest.test.ts index 46f345f51..5dda4be2f 100644 --- a/lib/research/__tests__/validateGetResearchLookupRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchLookupRequest.test.ts @@ -59,6 +59,16 @@ describe("validateGetResearchLookupRequest", () => { expect(res).toEqual({ accountId: "acc_1", spotifyId: "3TVXtAsR1Inumwj472S9r4" }); }); + it("returns 400 when direct spotifyId is not a 22-character Spotify artist ID", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest("http://localhost/api/research/lookup?spotifyId=abc"); + const res = await validateGetResearchLookupRequest(req); + + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toBe("spotifyId must be a valid Spotify artist ID"); + }); + it("returns 400 when url is not a Spotify artist URL", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest("http://localhost/api/research/lookup?url=https://google.com"); @@ -68,6 +78,18 @@ describe("validateGetResearchLookupRequest", () => { expect(body.error).toContain("Spotify artist URL"); }); + it("returns 400 when Spotify URL contains a malformed artist ID", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://open.spotify.com/artist/abc", + ); + const res = await validateGetResearchLookupRequest(req); + + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("Spotify artist URL"); + }); + it("extracts spotifyId from a valid URL", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest( diff --git a/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts b/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts index 1494883fe..fda181f90 100644 --- a/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts @@ -50,7 +50,7 @@ describe("validateGetResearchMetricsRequest", () => { if (result instanceof NextResponse) { expect(result.status).toBe(400); const body = await result.json(); - expect(body.error).toBe("artist parameter is required"); + expect(body.error).toBe("artist or id parameter is required"); } }); diff --git a/lib/research/getResearchCuratorHandler.ts b/lib/research/getResearchCuratorHandler.ts index 885b42281..a5acfee4d 100644 --- a/lib/research/getResearchCuratorHandler.ts +++ b/lib/research/getResearchCuratorHandler.ts @@ -7,8 +7,9 @@ import { validateGetResearchCuratorRequest } from "@/lib/research/validateGetRes /** * GET /api/research/curator * - * Returns details for a specific playlist curator. Not artist-scoped — keyed by - * `platform` and `id` query params, proxied to `/curator/{platform}/{id}`. + * Returns details for a specific playlist curator when the configured research + * provider supports legacy numeric curator identifiers. Not artist-scoped — + * keyed by `platform` and `id` query params, proxied to `/curator/{platform}/{id}`. * * @param request - The incoming HTTP request. * @returns The JSON response. diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts index fbbd04950..eb67567dc 100644 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -8,8 +8,8 @@ import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/instagram-posts * - * Returns recent Instagram posts for the given artist when the configured - * provider supports that dataset. + * Returns recent Instagram posts for the given artist through the legacy + * DeepSocial/Chartmetric dataset when the configured provider supports it. * Requires `artist` query param. * * @param request - The incoming HTTP request. diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts index 335b6762b..1afbe2e95 100644 --- a/lib/research/getResearchSearchHandler.ts +++ b/lib/research/getResearchSearchHandler.ts @@ -37,9 +37,11 @@ export async function getResearchSearchHandler(request: NextRequest): Promise; - -const UNSUPPORTED_RESULT: ProxyResult = { - status: 501, - data: { error: "Research data source does not support this endpoint" }, -}; - -function isRecord(value: unknown): value is JsonRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function firstRecord(value: unknown): JsonRecord | null { - if (Array.isArray(value)) return isRecord(value[0]) ? value[0] : null; - return isRecord(value) ? value : null; -} - -function extractList(value: unknown, keys: string[]): unknown[] { - if (Array.isArray(value)) return value; - if (!isRecord(value)) return []; - - for (const key of keys) { - const child = value[key]; - if (Array.isArray(child)) return child; - if (isRecord(child)) { - const nested = extractList(child, keys); - if (nested.length) return nested; - } - } - - return []; -} - -function pickString(record: JsonRecord, keys: string[]): string | undefined { - for (const key of keys) { - const value = record[key]; - if (typeof value === "string" || typeof value === "number") return String(value); - } - - return undefined; -} - -function normalizeArtistRecord(value: unknown): unknown { - if (!isRecord(value)) return value; - - const id = pickString(value, ["songstats_artist_id", "artist_id", "id"]); - return id ? { ...value, id } : value; -} - -function normalizeTrackRecord(value: unknown): unknown { - if (!isRecord(value)) return value; - - const id = pickString(value, ["songstats_track_id", "track_id", "id"]); - return id ? { ...value, id } : value; -} - -function normalizeArtistObject(value: unknown): unknown { - const record = firstRecord(value); - if (!record) return value; - return normalizeArtistRecord(record); -} - -function normalizeTrackObject(value: unknown): unknown { - const record = firstRecord(value); - if (!record) return value; - - const id = pickString(record, ["songstats_track_id", "track_id", "id"]); - return id ? { ...record, id } : record; -} - -function normalizeTrackLookupObject(value: unknown): unknown { - const record = firstRecord(value); - if (!record) return value; - - const id = pickString(record, ["songstats_track_id", "track_id", "id"]); - if (!id) return record; - - return { - ...record, - id, - songstats_track_ids: [id], - }; -} - -function normalizeUrlMap(value: unknown): JsonRecord { - const urls: JsonRecord = {}; - - const visit = (current: unknown, keyHint?: string): void => { - if (typeof current === "string") { - if (/^https?:\/\//i.test(current)) urls[keyHint || current] = current; - return; - } - - if (Array.isArray(current)) { - for (const item of current) visit(item, keyHint); - return; - } - - if (!isRecord(current)) return; - - const platform = pickString(current, ["platform", "source", "type", "name", "domain"]); - const url = pickString(current, ["url", "link", "href"]); - if (url && /^https?:\/\//i.test(url)) { - urls[platform || url] = url; - } - - for (const [key, child] of Object.entries(current)) { - visit(child, key); - } - }; - - visit(value); - return urls; -} - -async function mapSongstatsResult( - endpoint: string, - query?: Record, - normalize?: (value: unknown) => unknown, -): Promise { - const result = await fetchSongstats(endpoint, query); - if (result.status !== 200 || !normalize) return result; - return { status: result.status, data: normalize(result.data) }; -} - -function withoutLegacySearchParams(query?: Record): Record { - return { - q: query?.q || "", - ...(query?.limit ? { limit: query.limit } : {}), - ...(query?.offset ? { offset: query.offset } : {}), - }; -} +import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import { mapSongstatsArtistPath } from "@/lib/research/songstats/mapSongstatsArtistPath"; +import { mapSongstatsTrackPath } from "@/lib/research/songstats/mapSongstatsTrackPath"; +import { + extractList, + mapSongstatsResult, + normalizeArtistRecord, + normalizeTrackRecord, + UNSUPPORTED_RESULT, + withoutLegacySearchParams, +} from "@/lib/research/songstats/songstatsResearchMapping"; function mapEntitySearch( path: string, @@ -167,138 +40,6 @@ function mapEntitySearch( return Promise.resolve(UNSUPPORTED_RESULT); } -function mapArtistPath(path: string, query?: Record): Promise | null { - let match = path.match(/^\/artist\/spotify\/([^/]+)\/get-ids$/); - if (match) { - return mapSongstatsResult( - "/artists/info", - { spotify_artist_id: match[1] }, - normalizeArtistObject, - ); - } - - match = path.match(/^\/artist\/([^/]+)$/); - if (match) { - return mapSongstatsResult( - "/artists/info", - { songstats_artist_id: match[1] }, - normalizeArtistObject, - ); - } - - match = path.match(/^\/artist\/([^/]+)\/albums$/); - if (match) { - return mapSongstatsResult( - "/artists/catalog", - { songstats_artist_id: match[1], ...query }, - data => extractList(data, ["albums", "catalog", "tracks", "results", "data", "items"]), - ); - } - - match = path.match(/^\/artist\/([^/]+)\/tracks$/); - if (match) { - return mapSongstatsResult( - "/artists/catalog", - { songstats_artist_id: match[1], ...query }, - data => extractList(data, ["tracks", "catalog", "results", "data", "items"]), - ); - } - - match = path.match(/^\/artist\/([^/]+)\/stat\/([^/]+)$/); - if (match) { - return mapSongstatsResult("/artists/stats", { - songstats_artist_id: match[1], - source: match[2], - ...query, - }); - } - - match = path.match(/^\/artist\/([^/]+)\/([^/]+)-audience-stats$/); - if (match) { - return mapSongstatsResult("/artists/audience", { - songstats_artist_id: match[1], - source: match[2], - ...query, - }); - } - - match = path.match(/^\/artist\/([^/]+)\/(career|milestones|noteworthy-insights)$/); - if (match) { - return mapSongstatsResult( - "/artists/activities", - { songstats_artist_id: match[1], ...query }, - data => extractList(data, ["activities", "results", "data", "items"]), - ); - } - - match = path.match(/^\/artist\/([^/]+)\/urls$/); - if (match) { - return mapSongstatsResult("/artists/info", { songstats_artist_id: match[1] }, normalizeUrlMap); - } - - match = path.match(/^\/artist\/([^/]+)\/artist-rank$/); - if (match) { - return mapSongstatsResult("/artists/stats", { songstats_artist_id: match[1], ...query }); - } - - match = path.match(/^\/artist\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); - if (match) { - return mapSongstatsResult( - "/artists/top_playlists", - { - songstats_artist_id: match[1], - source: match[2], - status: match[3], - ...query, - }, - data => extractList(data, ["playlists", "results", "data", "items"]), - ); - } - - return null; -} - -function mapTrackPath(path: string, query?: Record): Promise | null { - let match = path.match(/^\/track\/isrc\/([^/]+)\/get-ids$/); - if (match) { - return mapSongstatsResult("/tracks/info", { isrc: match[1] }, normalizeTrackLookupObject); - } - - match = path.match(/^\/track\/spotify\/([^/]+)\/get-ids$/); - if (match) { - return mapSongstatsResult( - "/tracks/info", - { spotify_track_id: match[1] }, - normalizeTrackLookupObject, - ); - } - - match = path.match(/^\/track\/([^/]+)$/); - if (match) { - return mapSongstatsResult( - "/tracks/info", - { songstats_track_id: match[1] }, - normalizeTrackObject, - ); - } - - match = path.match(/^\/track\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); - if (match) { - return mapSongstatsResult( - "/tracks/activities", - { - songstats_track_id: match[1], - source: match[2], - status: match[3], - ...query, - }, - data => extractList(data, ["playlists", "activities", "results", "data", "items"]), - ); - } - - return null; -} - export async function fetchSongstatsResearch( path: string, query?: Record, @@ -307,8 +48,8 @@ export async function fetchSongstatsResearch( return ( (await mapEntitySearch(normalizedPath, query)) || - (await mapArtistPath(normalizedPath, query)) || - (await mapTrackPath(normalizedPath, query)) || + (await mapSongstatsArtistPath(normalizedPath, query)) || + (await mapSongstatsTrackPath(normalizedPath, query)) || UNSUPPORTED_RESULT ); } diff --git a/lib/research/songstats/mapSongstatsArtistPath.ts b/lib/research/songstats/mapSongstatsArtistPath.ts new file mode 100644 index 000000000..dd277a5dd --- /dev/null +++ b/lib/research/songstats/mapSongstatsArtistPath.ts @@ -0,0 +1,99 @@ +import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import { + extractList, + mapSongstatsResult, + normalizeArtistObject, + normalizeUrlMap, +} from "@/lib/research/songstats/songstatsResearchMapping"; + +export function mapSongstatsArtistPath( + path: string, + query?: Record, +): Promise | null { + let match = path.match(/^\/artist\/spotify\/([^/]+)\/get-ids$/); + if (match) { + return mapSongstatsResult( + "/artists/info", + { spotify_artist_id: match[1] }, + normalizeArtistObject, + ); + } + + match = path.match(/^\/artist\/([^/]+)$/); + if (match) { + return mapSongstatsResult( + "/artists/info", + { songstats_artist_id: match[1] }, + normalizeArtistObject, + ); + } + + match = path.match(/^\/artist\/([^/]+)\/albums$/); + if (match) { + return mapSongstatsResult( + "/artists/catalog", + { songstats_artist_id: match[1], ...query }, + data => extractList(data, ["albums", "catalog", "tracks", "results", "data", "items"]), + ); + } + + match = path.match(/^\/artist\/([^/]+)\/tracks$/); + if (match) { + return mapSongstatsResult( + "/artists/catalog", + { songstats_artist_id: match[1], ...query }, + data => extractList(data, ["tracks", "catalog", "results", "data", "items"]), + ); + } + + match = path.match(/^\/artist\/([^/]+)\/stat\/([^/]+)$/); + if (match) { + return mapSongstatsResult("/artists/stats", { + songstats_artist_id: match[1], + source: match[2], + ...query, + }); + } + + match = path.match(/^\/artist\/([^/]+)\/([^/]+)-audience-stats$/); + if (match) { + return mapSongstatsResult("/artists/audience", { + songstats_artist_id: match[1], + source: match[2], + ...query, + }); + } + + match = path.match(/^\/artist\/([^/]+)\/(career|milestones|noteworthy-insights)$/); + if (match) { + return mapSongstatsResult( + "/artists/activities", + { songstats_artist_id: match[1], ...query }, + data => extractList(data, ["activities", "results", "data", "items"]), + ); + } + + match = path.match(/^\/artist\/([^/]+)\/urls$/); + if (match) { + return mapSongstatsResult("/artists/info", { songstats_artist_id: match[1] }, normalizeUrlMap); + } + match = path.match(/^\/artist\/([^/]+)\/artist-rank$/); + if (match) { + return mapSongstatsResult("/artists/stats", { songstats_artist_id: match[1], ...query }); + } + match = path.match(/^\/artist\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); + if (match) { + return mapSongstatsResult( + "/artists/top_playlists", + { + songstats_artist_id: match[1], + source: match[2], + status: match[3], + ...query, + }, + data => extractList(data, ["playlists", "results", "data", "items"]), + ); + } + + return null; +} diff --git a/lib/research/songstats/mapSongstatsTrackPath.ts b/lib/research/songstats/mapSongstatsTrackPath.ts new file mode 100644 index 000000000..c1204397f --- /dev/null +++ b/lib/research/songstats/mapSongstatsTrackPath.ts @@ -0,0 +1,51 @@ +import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import { + extractList, + mapSongstatsResult, + normalizeTrackLookupObject, + normalizeTrackObject, +} from "@/lib/research/songstats/songstatsResearchMapping"; + +export function mapSongstatsTrackPath( + path: string, + query?: Record, +): Promise | null { + let match = path.match(/^\/track\/isrc\/([^/]+)\/get-ids$/); + if (match) { + return mapSongstatsResult("/tracks/info", { isrc: match[1] }, normalizeTrackLookupObject); + } + + match = path.match(/^\/track\/spotify\/([^/]+)\/get-ids$/); + if (match) { + return mapSongstatsResult( + "/tracks/info", + { spotify_track_id: match[1] }, + normalizeTrackLookupObject, + ); + } + + match = path.match(/^\/track\/([^/]+)$/); + if (match) { + return mapSongstatsResult( + "/tracks/info", + { songstats_track_id: match[1] }, + normalizeTrackObject, + ); + } + + match = path.match(/^\/track\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); + if (match) { + return mapSongstatsResult( + "/tracks/activities", + { + songstats_track_id: match[1], + source: match[2], + status: match[3], + ...query, + }, + data => extractList(data, ["playlists", "activities", "results", "data", "items"]), + ); + } + + return null; +} diff --git a/lib/research/songstats/songstatsResearchMapping.ts b/lib/research/songstats/songstatsResearchMapping.ts new file mode 100644 index 000000000..bea1ea470 --- /dev/null +++ b/lib/research/songstats/songstatsResearchMapping.ts @@ -0,0 +1,134 @@ +import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; + +export type JsonRecord = Record; + +export const UNSUPPORTED_RESULT: ProxyResult = { + status: 501, + data: { error: "Research data source does not support this endpoint" }, +}; + +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function firstRecord(value: unknown): JsonRecord | null { + if (Array.isArray(value)) return isRecord(value[0]) ? value[0] : null; + return isRecord(value) ? value : null; +} + +export function extractList(value: unknown, keys: string[]): unknown[] { + if (Array.isArray(value)) return value; + if (!isRecord(value)) return []; + + for (const key of keys) { + const child = value[key]; + if (Array.isArray(child)) return child; + if (isRecord(child)) { + const nested = extractList(child, keys); + if (nested.length) return nested; + } + } + + return []; +} + +function pickString(record: JsonRecord, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" || typeof value === "number") return String(value); + } + + return undefined; +} + +export function normalizeArtistRecord(value: unknown): unknown { + if (!isRecord(value)) return value; + + const id = pickString(value, ["songstats_artist_id", "artist_id", "id"]); + return id ? { ...value, id } : value; +} + +export function normalizeTrackRecord(value: unknown): unknown { + if (!isRecord(value)) return value; + + const id = pickString(value, ["songstats_track_id", "track_id", "id"]); + return id ? { ...value, id } : value; +} + +export function normalizeArtistObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + return normalizeArtistRecord(record); +} + +export function normalizeTrackObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + + const id = pickString(record, ["songstats_track_id", "track_id", "id"]); + return id ? { ...record, id } : record; +} + +export function normalizeTrackLookupObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + + const id = pickString(record, ["songstats_track_id", "track_id", "id"]); + if (!id) return record; + + return { + ...record, + id, + songstats_track_ids: [id], + }; +} + +export function normalizeUrlMap(value: unknown): JsonRecord { + const urls: JsonRecord = {}; + + const visit = (current: unknown, keyHint?: string): void => { + if (typeof current === "string") { + if (/^https?:\/\//i.test(current)) urls[keyHint || current] = current; + return; + } + + if (Array.isArray(current)) { + for (const item of current) visit(item, keyHint); + return; + } + + if (!isRecord(current)) return; + + const platform = pickString(current, ["platform", "source", "type", "name", "domain"]); + const url = pickString(current, ["url", "link", "href"]); + if (url && /^https?:\/\//i.test(url)) { + urls[platform || url] = url; + } + + for (const [key, child] of Object.entries(current)) { + visit(child, key); + } + }; + + visit(value); + return urls; +} + +export async function mapSongstatsResult( + endpoint: string, + query?: Record, + normalize?: (value: unknown) => unknown, +): Promise { + const result = await fetchSongstats(endpoint, query); + if (result.status !== 200 || !normalize) return result; + return { status: result.status, data: normalize(result.data) }; +} + +export function withoutLegacySearchParams(query?: Record): Record { + return { + q: query?.q || "", + ...(query?.limit ? { limit: query.limit } : {}), + ...(query?.offset ? { offset: query.offset } : {}), + }; +} diff --git a/lib/research/validateArtistRequest.ts b/lib/research/validateArtistRequest.ts index 9d88fc10d..517d345dc 100644 --- a/lib/research/validateArtistRequest.ts +++ b/lib/research/validateArtistRequest.ts @@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; import { errorResponse } from "@/lib/networking/errorResponse"; +import { PROVIDER_ID_REGEX } from "@/lib/research/providerId"; /** * Auth + artist identifier query param gate for artist-scoped research endpoints. @@ -21,9 +22,13 @@ export async function validateArtistRequest( const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) return authResult; - const artist = new URL(request.url).searchParams.get("artist"); - const artistId = new URL(request.url).searchParams.get("id") ?? undefined; - if (!artist && !artistId) return errorResponse("artist parameter is required", 400); + const { searchParams } = new URL(request.url); + const artist = searchParams.get("artist"); + const artistId = searchParams.get("id") ?? undefined; + if (!artist && !artistId) return errorResponse("artist or id parameter is required", 400); + if (artistId && !PROVIDER_ID_REGEX.test(artistId)) { + return errorResponse("id must be a provider artist ID", 400); + } const short = await ensureResearchCredits(authResult.accountId); if (short) return short; diff --git a/lib/research/validateGetResearchAlbumsRequest.ts b/lib/research/validateGetResearchAlbumsRequest.ts index 4d5ec1356..1a7be50b2 100644 --- a/lib/research/validateGetResearchAlbumsRequest.ts +++ b/lib/research/validateGetResearchAlbumsRequest.ts @@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; import { errorResponse } from "@/lib/networking/errorResponse"; +import { PROVIDER_ID_REGEX } from "@/lib/research/providerId"; export type ValidatedGetResearchAlbumsRequest = { accountId: string; @@ -12,7 +13,6 @@ export type ValidatedGetResearchAlbumsRequest = { }; const VALID_BOOLEAN = ["true", "false"] as const; -const PROVIDER_ID_REGEX = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; /** * Validates `GET /api/research/albums` — auth + required provider `artist_id`. diff --git a/lib/research/validateGetResearchCuratorRequest.ts b/lib/research/validateGetResearchCuratorRequest.ts index b949911e8..1693b9e97 100644 --- a/lib/research/validateGetResearchCuratorRequest.ts +++ b/lib/research/validateGetResearchCuratorRequest.ts @@ -14,7 +14,7 @@ export type ValidatedGetResearchCuratorRequest = { /** * Validates `GET /api/research/curator` — auth + required `platform` (enum) - * and `id` (numeric curator ID). + * and `id` (legacy provider numeric curator ID). * * @param request - The incoming HTTP request. */ diff --git a/lib/research/validateGetResearchLookupRequest.ts b/lib/research/validateGetResearchLookupRequest.ts index 8a3ea62e6..c47461786 100644 --- a/lib/research/validateGetResearchLookupRequest.ts +++ b/lib/research/validateGetResearchLookupRequest.ts @@ -3,8 +3,8 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; import { errorResponse } from "@/lib/networking/errorResponse"; -const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([a-zA-Z0-9]+)/; -const SPOTIFY_ID_REGEX = /^[A-Za-z0-9]{10,}$/; +const SPOTIFY_ID_REGEX = /^[A-Za-z0-9]{22}$/; +const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([A-Za-z0-9]{22})(?:[/?#]|$)/; export type ValidatedGetResearchLookupRequest = { accountId: string; diff --git a/lib/research/validateGetResearchTrackRequest.ts b/lib/research/validateGetResearchTrackRequest.ts index 5c694497c..ba1cb465f 100644 --- a/lib/research/validateGetResearchTrackRequest.ts +++ b/lib/research/validateGetResearchTrackRequest.ts @@ -2,14 +2,13 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; import { errorResponse } from "@/lib/networking/errorResponse"; +import { PROVIDER_ID_REGEX } from "@/lib/research/providerId"; export type ValidatedGetResearchTrackRequest = { accountId: string; id: string; }; -const PROVIDER_ID_REGEX = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; - /** * Validates `GET /api/research/track` — auth + required provider track `id`. * Discovery (search by name, filter by artist) is the caller's job via diff --git a/lib/songstats/__tests__/fetchSongstats.test.ts b/lib/songstats/__tests__/fetchSongstats.test.ts index dde0ef917..5e3de240a 100644 --- a/lib/songstats/__tests__/fetchSongstats.test.ts +++ b/lib/songstats/__tests__/fetchSongstats.test.ts @@ -8,10 +8,13 @@ describe("fetchSongstats", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV, SongStats_API: "songstats-key" }; vi.stubGlobal("fetch", vi.fn()); + vi.spyOn(console, "error").mockImplementation(() => undefined); }); afterEach(() => { + vi.useRealTimers(); process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -28,13 +31,14 @@ describe("fetchSongstats", () => { expect(result).toEqual({ status: 200, data: { results: [{ id: "artist_1" }] } }); expect(fetch).toHaveBeenCalledWith( "https://data.songstats.com/enterprise/v1/artists/search?q=Drake&limit=1", - { + expect.objectContaining({ method: "GET", + signal: expect.any(AbortSignal), headers: { accept: "application/json", apikey: "songstats-key", }, - }, + }), ); }); @@ -60,4 +64,38 @@ describe("fetchSongstats", () => { expect(result).toEqual({ status: 403, data: { error: "forbidden" } }); }); + + it("aborts SongStats requests after the configured timeout", async () => { + vi.useFakeTimers(); + process.env.SONGSTATS_TIMEOUT_MS = "25"; + vi.mocked(fetch).mockImplementation((_input, init) => { + const signal = (init as RequestInit).signal as AbortSignal; + return new Promise((_, reject) => { + signal.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + }); + + const resultPromise = fetchSongstats("/artists/search", { q: "Drake" }); + await vi.advanceTimersByTimeAsync(25); + + await expect(resultPromise).resolves.toEqual({ + status: 504, + data: { error: "SongStats request timed out" }, + }); + }); + + it("returns a sanitized 500-compatible result when fetch fails", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("network down")); + + const result = await fetchSongstats("/artists/search", { q: "Drake" }); + + expect(result).toEqual({ + status: 500, + data: { error: "SongStats request failed" }, + }); + }); }); diff --git a/lib/songstats/fetchSongstats.ts b/lib/songstats/fetchSongstats.ts index 0fb178b58..90b3de349 100644 --- a/lib/songstats/fetchSongstats.ts +++ b/lib/songstats/fetchSongstats.ts @@ -1,9 +1,7 @@ import { SONGSTATS_BASE } from "@/lib/songstats/songstatsBase"; +import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; -interface ProxyResult { - data: unknown; - status: number; -} +const DEFAULT_SONGSTATS_TIMEOUT_MS = 10_000; function appendQueryParams(url: URL, queryParams?: Record): void { if (!queryParams) return; @@ -23,10 +21,20 @@ async function parseSongstatsResponse(response: Response): Promise { return text ? { raw: text } : null; } +function getSongstatsTimeoutMs(): number { + const configured = Number.parseInt(process.env.SONGSTATS_TIMEOUT_MS ?? "", 10); + return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_SONGSTATS_TIMEOUT_MS; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + export async function fetchSongstats( path: string, queryParams?: Record, ): Promise { + // Product setup uses this exact mixed-case environment variable name. const apiKey = process.env.SongStats_API; if (!apiKey) { return { @@ -39,17 +47,30 @@ export async function fetchSongstats( const url = new URL(`${SONGSTATS_BASE}/enterprise/v1/${path.replace(/^\/+/, "")}`); appendQueryParams(url, queryParams); - const response = await fetch(url.toString(), { - method: "GET", - headers: { - accept: "application/json", - apikey: apiKey, - }, - }); - - const data = await parseSongstatsResponse(response); - return { data, status: response.status }; - } catch { - return { data: null, status: 500 }; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), getSongstatsTimeoutMs()); + + try { + const response = await fetch(url.toString(), { + method: "GET", + signal: controller.signal, + headers: { + accept: "application/json", + apikey: apiKey, + }, + }); + + const data = await parseSongstatsResponse(response); + return { data, status: response.status }; + } finally { + clearTimeout(timeout); + } + } catch (error) { + console.error("[ERROR] fetchSongstats:", error); + if (isAbortError(error)) { + return { data: { error: "SongStats request timed out" }, status: 504 }; + } + + return { data: { error: "SongStats request failed" }, status: 500 }; } } From 71eb0671034a21610fabec9218d0d3666d389615 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:54:34 -0400 Subject: [PATCH 03/21] fix: use uppercase SongStats API key env --- .env.example | 2 ++ .../__tests__/fetchSongstats.test.ts | 31 +++++++++++++++++-- lib/songstats/fetchSongstats.ts | 9 ++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 985e8c72a..a60daf92a 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ RESEND_API_KEY= PERPLEXITY_API_KEY= SERPAPI_API_KEY= ANTHROPIC_API_KEY= +SONGSTATS_API_KEY= +# Legacy alias also supported: SongStats_API= # Research provider override. Defaults to SongStats when unset. diff --git a/lib/songstats/__tests__/fetchSongstats.test.ts b/lib/songstats/__tests__/fetchSongstats.test.ts index 5e3de240a..424de6034 100644 --- a/lib/songstats/__tests__/fetchSongstats.test.ts +++ b/lib/songstats/__tests__/fetchSongstats.test.ts @@ -6,7 +6,7 @@ const ORIGINAL_ENV = process.env; describe("fetchSongstats", () => { beforeEach(() => { - process.env = { ...ORIGINAL_ENV, SongStats_API: "songstats-key" }; + process.env = { ...ORIGINAL_ENV, SONGSTATS_API_KEY: "songstats-key" }; vi.stubGlobal("fetch", vi.fn()); vi.spyOn(console, "error").mockImplementation(() => undefined); }); @@ -42,13 +42,38 @@ describe("fetchSongstats", () => { ); }); - it("returns a 500-compatible result when SongStats_API is not configured", async () => { + it("uses the legacy SongStats_API env var when SONGSTATS_API_KEY is not configured", async () => { + delete process.env.SONGSTATS_API_KEY; + process.env.SongStats_API = "legacy-songstats-key"; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ results: [] }), + headers: new Headers({ "content-type": "application/json" }), + } as Response); + + await fetchSongstats("/artists/search", { q: "Drake" }); + + expect(fetch).toHaveBeenCalledWith( + "https://data.songstats.com/enterprise/v1/artists/search?q=Drake", + expect.objectContaining({ + headers: expect.objectContaining({ + apikey: "legacy-songstats-key", + }), + }), + ); + }); + + it("returns a 500-compatible result when no SongStats API key is configured", async () => { + delete process.env.SONGSTATS_API_KEY; delete process.env.SongStats_API; const result = await fetchSongstats("/artists/search", { q: "Drake" }); expect(result.status).toBe(500); - expect(result.data).toEqual({ error: "SongStats_API environment variable is not set" }); + expect(result.data).toEqual({ + error: "SONGSTATS_API_KEY or SongStats_API environment variable is not set", + }); expect(fetch).not.toHaveBeenCalled(); }); diff --git a/lib/songstats/fetchSongstats.ts b/lib/songstats/fetchSongstats.ts index 90b3de349..6a3440533 100644 --- a/lib/songstats/fetchSongstats.ts +++ b/lib/songstats/fetchSongstats.ts @@ -30,15 +30,18 @@ function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } +function getSongstatsApiKey(): string | undefined { + return process.env.SONGSTATS_API_KEY || process.env.SongStats_API; +} + export async function fetchSongstats( path: string, queryParams?: Record, ): Promise { - // Product setup uses this exact mixed-case environment variable name. - const apiKey = process.env.SongStats_API; + const apiKey = getSongstatsApiKey(); if (!apiKey) { return { - data: { error: "SongStats_API environment variable is not set" }, + data: { error: "SONGSTATS_API_KEY or SongStats_API environment variable is not set" }, status: 500, }; } From 02ae4c77c9e3a95ca5c7283da7a9138010208c12 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:59:24 -0400 Subject: [PATCH 04/21] fix: use SongStats API host --- lib/songstats/__tests__/fetchSongstats.test.ts | 4 ++-- lib/songstats/songstatsBase.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/songstats/__tests__/fetchSongstats.test.ts b/lib/songstats/__tests__/fetchSongstats.test.ts index 424de6034..688f161b9 100644 --- a/lib/songstats/__tests__/fetchSongstats.test.ts +++ b/lib/songstats/__tests__/fetchSongstats.test.ts @@ -30,7 +30,7 @@ describe("fetchSongstats", () => { expect(result).toEqual({ status: 200, data: { results: [{ id: "artist_1" }] } }); expect(fetch).toHaveBeenCalledWith( - "https://data.songstats.com/enterprise/v1/artists/search?q=Drake&limit=1", + "https://api.songstats.com/enterprise/v1/artists/search?q=Drake&limit=1", expect.objectContaining({ method: "GET", signal: expect.any(AbortSignal), @@ -55,7 +55,7 @@ describe("fetchSongstats", () => { await fetchSongstats("/artists/search", { q: "Drake" }); expect(fetch).toHaveBeenCalledWith( - "https://data.songstats.com/enterprise/v1/artists/search?q=Drake", + "https://api.songstats.com/enterprise/v1/artists/search?q=Drake", expect.objectContaining({ headers: expect.objectContaining({ apikey: "legacy-songstats-key", diff --git a/lib/songstats/songstatsBase.ts b/lib/songstats/songstatsBase.ts index 48cad66b6..fc81fea09 100644 --- a/lib/songstats/songstatsBase.ts +++ b/lib/songstats/songstatsBase.ts @@ -1,2 +1,2 @@ /** Base URL for the SongStats Enterprise API. */ -export const SONGSTATS_BASE = "https://data.songstats.com"; +export const SONGSTATS_BASE = "https://api.songstats.com"; From 5752953934d3265fd4f56b90ddcfd7de98c3cdde Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:17:50 -0400 Subject: [PATCH 05/21] fix: map SongStats metric sources --- .../__tests__/fetchSongstatsResearch.test.ts | 38 +++++++++++++++++++ .../songstats/mapSongstatsArtistPath.ts | 28 ++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts index 51e292874..2a1be4ad1 100644 --- a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts +++ b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts @@ -82,4 +82,42 @@ describe("fetchSongstatsResearch", () => { }); expect(fetchSongstats).not.toHaveBeenCalled(); }); + + it("maps public platform metric sources to SongStats metric source IDs", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ + status: 200, + data: { stats: [] }, + }); + + await fetchSongstatsResearch("/artist/artist_1/stat/spotify"); + + expect(fetchSongstats).toHaveBeenCalledWith("/artists/stats", { + songstats_artist_id: "artist_1", + source: "spotify_streams", + }); + }); + + it("maps public audience platforms to SongStats metric source IDs", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ + status: 200, + data: { audience: [] }, + }); + + await fetchSongstatsResearch("/artist/artist_1/instagram-audience-stats"); + + expect(fetchSongstats).toHaveBeenCalledWith("/artists/audience", { + songstats_artist_id: "artist_1", + source: "instagram_followers", + }); + }); + + it("treats artist rank as unsupported for SongStats instead of querying broad stats", async () => { + const result = await fetchSongstatsResearch("/artist/artist_1/artist-rank"); + + expect(result).toEqual({ + status: 501, + data: { error: "Research data source does not support this endpoint" }, + }); + expect(fetchSongstats).not.toHaveBeenCalled(); + }); }); diff --git a/lib/research/songstats/mapSongstatsArtistPath.ts b/lib/research/songstats/mapSongstatsArtistPath.ts index dd277a5dd..6284e481b 100644 --- a/lib/research/songstats/mapSongstatsArtistPath.ts +++ b/lib/research/songstats/mapSongstatsArtistPath.ts @@ -4,8 +4,30 @@ import { mapSongstatsResult, normalizeArtistObject, normalizeUrlMap, + UNSUPPORTED_RESULT, } from "@/lib/research/songstats/songstatsResearchMapping"; +const SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM: Record = { + bandsintown: "bandsintown_followers", + deezer: "deezer_fans", + facebook: "facebook_likes", + instagram: "instagram_followers", + line: "line_followers", + melon: "melon_followers", + soundcloud: "soundcloud_followers", + spotify: "spotify_streams", + tiktok: "tiktok_followers", + twitch: "twitch_followers", + twitter: "twitter_followers", + wikipedia: "wikipedia_views", + youtube_artist: "youtube_artist_subscribers", + youtube_channel: "youtube_channel_subscribers", +}; + +function mapArtistMetricSource(source: string): string { + return SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM[source] || source; +} + export function mapSongstatsArtistPath( path: string, query?: Record, @@ -50,7 +72,7 @@ export function mapSongstatsArtistPath( if (match) { return mapSongstatsResult("/artists/stats", { songstats_artist_id: match[1], - source: match[2], + source: mapArtistMetricSource(match[2]), ...query, }); } @@ -59,7 +81,7 @@ export function mapSongstatsArtistPath( if (match) { return mapSongstatsResult("/artists/audience", { songstats_artist_id: match[1], - source: match[2], + source: mapArtistMetricSource(match[2]), ...query, }); } @@ -79,7 +101,7 @@ export function mapSongstatsArtistPath( } match = path.match(/^\/artist\/([^/]+)\/artist-rank$/); if (match) { - return mapSongstatsResult("/artists/stats", { songstats_artist_id: match[1], ...query }); + return Promise.resolve(UNSUPPORTED_RESULT); } match = path.match(/^\/artist\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); if (match) { From 0a9df31dbd954e0b7abae981487b8062815a46f5 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:36:56 -0400 Subject: [PATCH 06/21] fix: allow SongStats radio metric sources --- .../__tests__/validateGetResearchMetricsRequest.test.ts | 7 +++++++ lib/research/validateGetResearchMetricsRequest.ts | 2 ++ 2 files changed, 9 insertions(+) diff --git a/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts b/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts index fda181f90..ecb3e9f96 100644 --- a/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts @@ -60,4 +60,11 @@ describe("validateGetResearchMetricsRequest", () => { ); expect(result).toEqual({ accountId: "acc_1", artist: "Drake", source: "spotify" }); }); + + it("accepts SongStats radio metric sources", async () => { + const result = await validateGetResearchMetricsRequest( + new NextRequest("http://x/?artist=Drake&source=radio"), + ); + expect(result).toEqual({ accountId: "acc_1", artist: "Drake", source: "radio" }); + }); }); diff --git a/lib/research/validateGetResearchMetricsRequest.ts b/lib/research/validateGetResearchMetricsRequest.ts index a85207767..512b75e85 100644 --- a/lib/research/validateGetResearchMetricsRequest.ts +++ b/lib/research/validateGetResearchMetricsRequest.ts @@ -17,6 +17,8 @@ const VALID_SOURCES = [ "melon", "wikipedia", "bandsintown", + "radio", + "sxm", ] as const; export type ValidatedGetResearchMetricsRequest = { From ab3c2eccff8ebfdab651b02067ad71b3e7f12c38 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:49:30 -0400 Subject: [PATCH 07/21] fix: map SongStats related artists --- .../__tests__/fetchSongstatsResearch.test.ts | 33 +++++++++++++++++++ .../songstats/mapSongstatsArtistPath.ts | 20 +++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts index 2a1be4ad1..cc23e88d0 100644 --- a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts +++ b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts @@ -120,4 +120,37 @@ describe("fetchSongstatsResearch", () => { }); expect(fetchSongstats).not.toHaveBeenCalled(); }); + + it("maps similar artists to SongStats related artists without forwarding unsupported weights", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ + status: 200, + data: { + artist_info: { + related_artists: [ + { songstats_artist_id: "artist_2", name: "Kendrick Lamar" }, + { songstats_artist_id: "artist_3", name: "J. Cole" }, + ], + }, + }, + }); + + const result = await fetchSongstatsResearch( + "/artist/artist_1/similar-artists/by-configurations", + { + audience: "high", + genre: "medium", + mood: "low", + musicality: "medium", + limit: "1", + }, + ); + + expect(fetchSongstats).toHaveBeenCalledWith("/artists/info", { + songstats_artist_id: "artist_1", + }); + expect(result).toEqual({ + status: 200, + data: [{ id: "artist_2", songstats_artist_id: "artist_2", name: "Kendrick Lamar" }], + }); + }); }); diff --git a/lib/research/songstats/mapSongstatsArtistPath.ts b/lib/research/songstats/mapSongstatsArtistPath.ts index 6284e481b..260f7eae8 100644 --- a/lib/research/songstats/mapSongstatsArtistPath.ts +++ b/lib/research/songstats/mapSongstatsArtistPath.ts @@ -3,6 +3,7 @@ import { extractList, mapSongstatsResult, normalizeArtistObject, + normalizeArtistRecord, normalizeUrlMap, UNSUPPORTED_RESULT, } from "@/lib/research/songstats/songstatsResearchMapping"; @@ -28,6 +29,13 @@ function mapArtistMetricSource(source: string): string { return SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM[source] || source; } +function parsePositiveLimit(value?: string): number | undefined { + if (!value) return undefined; + + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} + export function mapSongstatsArtistPath( path: string, query?: Record, @@ -103,6 +111,18 @@ export function mapSongstatsArtistPath( if (match) { return Promise.resolve(UNSUPPORTED_RESULT); } + + match = path.match(/^\/artist\/([^/]+)\/similar-artists\/by-configurations$/); + if (match) { + const limit = parsePositiveLimit(query?.limit); + return mapSongstatsResult("/artists/info", { songstats_artist_id: match[1] }, data => { + const relatedArtists = extractList(data, ["artist_info", "related_artists"]).map( + normalizeArtistRecord, + ); + return limit ? relatedArtists.slice(0, limit) : relatedArtists; + }); + } + match = path.match(/^\/artist\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); if (match) { return mapSongstatsResult( From 73925ae95395074c34b1d79bb276ed5c3e4d6081 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:15:01 -0400 Subject: [PATCH 08/21] fix: use SongStats stats source ids --- .../__tests__/fetchSongstatsResearch.test.ts | 4 +-- .../songstats/mapSongstatsArtistPath.ts | 26 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts index cc23e88d0..4c7e24f16 100644 --- a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts +++ b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts @@ -83,7 +83,7 @@ describe("fetchSongstatsResearch", () => { expect(fetchSongstats).not.toHaveBeenCalled(); }); - it("maps public platform metric sources to SongStats metric source IDs", async () => { + it("maps public platform metric sources to SongStats stats source IDs", async () => { vi.mocked(fetchSongstats).mockResolvedValue({ status: 200, data: { stats: [] }, @@ -93,7 +93,7 @@ describe("fetchSongstatsResearch", () => { expect(fetchSongstats).toHaveBeenCalledWith("/artists/stats", { songstats_artist_id: "artist_1", - source: "spotify_streams", + source: "spotify", }); }); diff --git a/lib/research/songstats/mapSongstatsArtistPath.ts b/lib/research/songstats/mapSongstatsArtistPath.ts index 260f7eae8..97a0f7ffa 100644 --- a/lib/research/songstats/mapSongstatsArtistPath.ts +++ b/lib/research/songstats/mapSongstatsArtistPath.ts @@ -25,10 +25,30 @@ const SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM: Record = { youtube_channel: "youtube_channel_subscribers", }; -function mapArtistMetricSource(source: string): string { +const SONGSTATS_ARTIST_STATS_SOURCE_BY_PLATFORM: Record = { + amazon: "amazon", + bandsintown: "bandsintown", + deezer: "deezer", + facebook: "facebook", + instagram: "instagram", + radio: "radio", + soundcloud: "soundcloud", + spotify: "spotify", + sxm: "sxm", + tiktok: "tiktok", + twitter: "twitter", + youtube_artist: "youtube", + youtube_channel: "youtube", +}; + +function mapArtistAudienceSource(source: string): string { return SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM[source] || source; } +function mapArtistStatsSource(source: string): string { + return SONGSTATS_ARTIST_STATS_SOURCE_BY_PLATFORM[source] || source; +} + function parsePositiveLimit(value?: string): number | undefined { if (!value) return undefined; @@ -80,7 +100,7 @@ export function mapSongstatsArtistPath( if (match) { return mapSongstatsResult("/artists/stats", { songstats_artist_id: match[1], - source: mapArtistMetricSource(match[2]), + source: mapArtistStatsSource(match[2]), ...query, }); } @@ -89,7 +109,7 @@ export function mapSongstatsArtistPath( if (match) { return mapSongstatsResult("/artists/audience", { songstats_artist_id: match[1], - source: mapArtistMetricSource(match[2]), + source: mapArtistAudienceSource(match[2]), ...query, }); } From d6f808e8c28e5e08db6fba8c669126273168996b Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:23:57 -0400 Subject: [PATCH 09/21] fix: allow slow SongStats stats responses --- app/api/research/metrics/route.ts | 2 ++ .../__tests__/fetchSongstats.test.ts | 32 +++++++++++++++++++ lib/songstats/fetchSongstats.ts | 21 ++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app/api/research/metrics/route.ts b/app/api/research/metrics/route.ts index 8cd2f12b4..9241f3edb 100644 --- a/app/api/research/metrics/route.ts +++ b/app/api/research/metrics/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getResearchMetricsHandler } from "@/lib/research/getResearchMetricsHandler"; +export const maxDuration = 60; + /** * OPTIONS /api/research/metrics — CORS preflight. * diff --git a/lib/songstats/__tests__/fetchSongstats.test.ts b/lib/songstats/__tests__/fetchSongstats.test.ts index 688f161b9..4760213d0 100644 --- a/lib/songstats/__tests__/fetchSongstats.test.ts +++ b/lib/songstats/__tests__/fetchSongstats.test.ts @@ -113,6 +113,38 @@ describe("fetchSongstats", () => { }); }); + it("allows slower SongStats stats requests before aborting", async () => { + vi.useFakeTimers(); + let abortCount = 0; + vi.mocked(fetch).mockImplementation((_input, init) => { + const signal = (init as RequestInit).signal as AbortSignal; + return new Promise((_, reject) => { + signal.addEventListener("abort", () => { + abortCount += 1; + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + }); + + const resultPromise = fetchSongstats("/artists/stats", { + songstats_artist_id: "artist_1", + source: "spotify", + }); + await vi.advanceTimersByTimeAsync(10_000); + + expect(abortCount).toBe(0); + + await vi.advanceTimersByTimeAsync(25_000); + + expect(abortCount).toBe(1); + await expect(resultPromise).resolves.toEqual({ + status: 504, + data: { error: "SongStats request timed out" }, + }); + }); + it("returns a sanitized 500-compatible result when fetch fails", async () => { vi.mocked(fetch).mockRejectedValue(new Error("network down")); diff --git a/lib/songstats/fetchSongstats.ts b/lib/songstats/fetchSongstats.ts index 6a3440533..d929b6ce6 100644 --- a/lib/songstats/fetchSongstats.ts +++ b/lib/songstats/fetchSongstats.ts @@ -2,6 +2,13 @@ import { SONGSTATS_BASE } from "@/lib/songstats/songstatsBase"; import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; const DEFAULT_SONGSTATS_TIMEOUT_MS = 10_000; +const SLOW_SONGSTATS_TIMEOUT_MS = 35_000; +const SLOW_SONGSTATS_PATHS = new Set([ + "artists/historic_stats", + "artists/stats", + "tracks/historic_stats", + "tracks/stats", +]); function appendQueryParams(url: URL, queryParams?: Record): void { if (!queryParams) return; @@ -21,9 +28,17 @@ async function parseSongstatsResponse(response: Response): Promise { return text ? { raw: text } : null; } -function getSongstatsTimeoutMs(): number { +function getDefaultSongstatsTimeoutMs(path: string): number { + return SLOW_SONGSTATS_PATHS.has(path.replace(/^\/+/, "")) + ? SLOW_SONGSTATS_TIMEOUT_MS + : DEFAULT_SONGSTATS_TIMEOUT_MS; +} + +function getSongstatsTimeoutMs(path: string): number { const configured = Number.parseInt(process.env.SONGSTATS_TIMEOUT_MS ?? "", 10); - return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_SONGSTATS_TIMEOUT_MS; + return Number.isFinite(configured) && configured > 0 + ? configured + : getDefaultSongstatsTimeoutMs(path); } function isAbortError(error: unknown): boolean { @@ -51,7 +66,7 @@ export async function fetchSongstats( appendQueryParams(url, queryParams); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), getSongstatsTimeoutMs()); + const timeout = setTimeout(() => controller.abort(), getSongstatsTimeoutMs(path)); try { const response = await fetch(url.toString(), { From d76098f16ef23ca627e1301472fe6caa54c6ed89 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:47:39 -0400 Subject: [PATCH 10/21] fix: cache research metrics refreshes --- .../refreshResearchCacheStep.test.ts | 83 ++++++++ app/workflows/refreshResearchCacheStep.ts | 61 ++++++ app/workflows/refreshResearchCacheWorkflow.ts | 10 + .../getResearchMetricsCacheHandler.test.ts | 187 ++++++++++++++++++ .../getResearchMetricsHandler.test.ts | 4 + .../cache/ResearchCacheRefreshInput.ts | 12 ++ lib/research/cache/buildResearchCacheKey.ts | 27 +++ .../cache/getResearchMetricsWithCache.ts | 132 +++++++++++++ .../cache/kickRefreshResearchCacheWorkflow.ts | 32 +++ lib/research/cache/researchCacheConfig.ts | 2 + lib/research/getResearchMetricsHandler.ts | 20 +- .../selectResearchCacheEntry.ts | 19 ++ .../upsertResearchCacheEntry.ts | 19 ++ types/database.types.ts | 63 ++++++ 14 files changed, 665 insertions(+), 6 deletions(-) create mode 100644 app/workflows/__tests__/refreshResearchCacheStep.test.ts create mode 100644 app/workflows/refreshResearchCacheStep.ts create mode 100644 app/workflows/refreshResearchCacheWorkflow.ts create mode 100644 lib/research/__tests__/getResearchMetricsCacheHandler.test.ts create mode 100644 lib/research/cache/ResearchCacheRefreshInput.ts create mode 100644 lib/research/cache/buildResearchCacheKey.ts create mode 100644 lib/research/cache/getResearchMetricsWithCache.ts create mode 100644 lib/research/cache/kickRefreshResearchCacheWorkflow.ts create mode 100644 lib/research/cache/researchCacheConfig.ts create mode 100644 lib/supabase/research_cache_entries/selectResearchCacheEntry.ts create mode 100644 lib/supabase/research_cache_entries/upsertResearchCacheEntry.ts diff --git a/app/workflows/__tests__/refreshResearchCacheStep.test.ts b/app/workflows/__tests__/refreshResearchCacheStep.test.ts new file mode 100644 index 000000000..01e4987cc --- /dev/null +++ b/app/workflows/__tests__/refreshResearchCacheStep.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { refreshResearchCacheStep } from "../refreshResearchCacheStep"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; + +vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ + fetchResearchProvider: vi.fn(), +})); + +vi.mock("@/lib/supabase/research_cache_entries/upsertResearchCacheEntry", () => ({ + upsertResearchCacheEntry: vi.fn(), +})); + +describe("refreshResearchCacheStep", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + it("stores successful provider responses as ready cache entries", async () => { + vi.mocked(fetchResearchProvider).mockResolvedValue({ + status: 200, + data: { stats: [{ source: "spotify" }] }, + }); + + await refreshResearchCacheStep({ + cacheKey: "cache_1", + provider: "songstats", + endpoint: "artist_metrics", + entityType: "artist", + entityId: "artist_1", + source: "spotify", + query: {}, + path: "/artist/artist_1/stat/spotify", + }); + + expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/artist_1/stat/spotify", {}); + expect(upsertResearchCacheEntry).toHaveBeenCalledWith( + expect.objectContaining({ + cache_key: "cache_1", + provider: "songstats", + endpoint: "artist_metrics", + entity_type: "artist", + entity_id: "artist_1", + source: "spotify", + data: { stats: [{ source: "spotify" }] }, + raw_data: { stats: [{ source: "spotify" }] }, + status: "ready", + status_code: 200, + error: null, + refresh_started_at: null, + }), + ); + }); + + it("records failed refreshes without clearing cached data", async () => { + vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 504, data: null }); + + await refreshResearchCacheStep({ + cacheKey: "cache_1", + provider: "songstats", + endpoint: "artist_metrics", + entityType: "artist", + entityId: "artist_1", + source: "spotify", + query: {}, + path: "/artist/artist_1/stat/spotify", + }); + + expect(upsertResearchCacheEntry).toHaveBeenCalledWith( + expect.objectContaining({ + cache_key: "cache_1", + status: "failed", + status_code: 504, + error: "Request failed with status 504", + refresh_started_at: null, + }), + ); + expect(vi.mocked(upsertResearchCacheEntry).mock.calls[0][0]).not.toHaveProperty("data"); + }); +}); diff --git a/app/workflows/refreshResearchCacheStep.ts b/app/workflows/refreshResearchCacheStep.ts new file mode 100644 index 000000000..5ceb1cdd6 --- /dev/null +++ b/app/workflows/refreshResearchCacheStep.ts @@ -0,0 +1,61 @@ +import type { Json } from "@/types/database.types"; +import type { ResearchCacheRefreshInput } from "@/lib/research/cache/ResearchCacheRefreshInput"; +import { RESEARCH_METRICS_CACHE_TTL_MS } from "@/lib/research/cache/researchCacheConfig"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; + +function toJson(value: unknown): Json { + return JSON.parse(JSON.stringify(value ?? null)) as Json; +} + +function expiresAtForEndpoint(endpoint: string, now: Date): string { + const ttl = endpoint === "artist_metrics" ? RESEARCH_METRICS_CACHE_TTL_MS : 60 * 60 * 1000; + return new Date(now.getTime() + ttl).toISOString(); +} + +export async function refreshResearchCacheStep(input: ResearchCacheRefreshInput): Promise { + "use step"; + + console.log(`[research-cache] Refreshing ${input.endpoint}:${input.entityId}`); + const result = await fetchResearchProvider(input.path, input.query); + const now = new Date(); + + if (result.status === 200) { + await upsertResearchCacheEntry({ + cache_key: input.cacheKey, + provider: input.provider, + endpoint: input.endpoint, + entity_type: input.entityType, + entity_id: input.entityId, + source: input.source ?? null, + query: input.query, + data: toJson(result.data), + raw_data: toJson(result.data), + status: "ready", + status_code: result.status, + error: null, + fetched_at: now.toISOString(), + expires_at: expiresAtForEndpoint(input.endpoint, now), + refresh_started_at: null, + }); + console.log(`[research-cache] Refreshed ${input.endpoint}:${input.entityId}`); + return; + } + + await upsertResearchCacheEntry({ + cache_key: input.cacheKey, + provider: input.provider, + endpoint: input.endpoint, + entity_type: input.entityType, + entity_id: input.entityId, + source: input.source ?? null, + query: input.query, + status: "failed", + status_code: result.status, + error: `Request failed with status ${result.status}`, + refresh_started_at: null, + }); + console.warn( + `[research-cache] Refresh failed for ${input.endpoint}:${input.entityId} status=${result.status}`, + ); +} diff --git a/app/workflows/refreshResearchCacheWorkflow.ts b/app/workflows/refreshResearchCacheWorkflow.ts new file mode 100644 index 000000000..11ebf0534 --- /dev/null +++ b/app/workflows/refreshResearchCacheWorkflow.ts @@ -0,0 +1,10 @@ +import type { ResearchCacheRefreshInput } from "@/lib/research/cache/ResearchCacheRefreshInput"; +import { refreshResearchCacheStep } from "@/app/workflows/refreshResearchCacheStep"; + +export async function refreshResearchCacheWorkflow(input: ResearchCacheRefreshInput) { + "use workflow"; + + console.log(`[research-cache] workflow:start ${input.endpoint}:${input.entityId}`); + await refreshResearchCacheStep(input); + console.log(`[research-cache] workflow:done ${input.endpoint}:${input.entityId}`); +} diff --git a/lib/research/__tests__/getResearchMetricsCacheHandler.test.ts b/lib/research/__tests__/getResearchMetricsCacheHandler.test.ts new file mode 100644 index 000000000..41d3ab3c4 --- /dev/null +++ b/lib/research/__tests__/getResearchMetricsCacheHandler.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { getResearchMetricsHandler } from "../getResearchMetricsHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { selectResearchCacheEntry } from "@/lib/supabase/research_cache_entries/selectResearchCacheEntry"; +import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; +import { kickRefreshResearchCacheWorkflow } from "@/lib/research/cache/kickRefreshResearchCacheWorkflow"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/research/ensureResearchCredits", () => ({ + ensureResearchCredits: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/research/resolveArtist", () => ({ + resolveArtist: vi.fn(), +})); + +vi.mock("@/lib/supabase/research_cache_entries/selectResearchCacheEntry", () => ({ + selectResearchCacheEntry: vi.fn(), +})); + +vi.mock("@/lib/supabase/research_cache_entries/upsertResearchCacheEntry", () => ({ + upsertResearchCacheEntry: vi.fn(), +})); + +vi.mock("@/lib/research/cache/kickRefreshResearchCacheWorkflow", () => ({ + kickRefreshResearchCacheWorkflow: vi.fn(), +})); + +vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ + fetchResearchProvider: vi.fn(), +})); + +vi.mock("@/lib/credits/recordCreditDeduction", () => ({ + recordCreditDeduction: vi.fn(), +})); + +function request(url: string) { + return new NextRequest(url, { headers: { "x-api-key": "test" } }); +} + +function cacheRow(overrides: Record = {}) { + return { + cache_key: "cache_1", + provider: "songstats", + endpoint: "artist_metrics", + entity_type: "artist", + entity_id: "artist_1", + source: "spotify", + query: {}, + data: { stats: [{ source: "spotify", data: { streams_total: 100 } }] }, + raw_data: { stats: [{ source: "spotify", data: { streams_total: 100 } }] }, + status: "ready", + status_code: 200, + error: null, + fetched_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 60_000).toISOString(), + refresh_started_at: null, + refresh_run_id: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +describe("getResearchMetricsHandler cache behavior", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account_1", + orgId: null, + authToken: "token", + }); + vi.mocked(resolveArtist).mockResolvedValue({ id: "artist_1" }); + vi.mocked(recordCreditDeduction).mockResolvedValue(undefined as never); + vi.mocked(upsertResearchCacheEntry).mockResolvedValue(null as never); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns fresh cached metrics without calling the provider", async () => { + vi.mocked(selectResearchCacheEntry).mockResolvedValue(cacheRow() as never); + + const res = await getResearchMetricsHandler( + request("http://localhost/api/research/metrics?artist=Drake&source=spotify"), + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ + status: "success", + stats: [{ source: "spotify", data: { streams_total: 100 } }], + }); + expect(fetchResearchProvider).not.toHaveBeenCalled(); + expect(kickRefreshResearchCacheWorkflow).not.toHaveBeenCalled(); + expect(recordCreditDeduction).toHaveBeenCalledWith({ + accountId: "account_1", + creditsToDeduct: 5, + source: "api", + }); + }); + + it("returns stale cached metrics and queues a background refresh", async () => { + vi.mocked(selectResearchCacheEntry).mockResolvedValue( + cacheRow({ + expires_at: new Date(Date.now() - 60_000).toISOString(), + }) as never, + ); + + const res = await getResearchMetricsHandler( + request("http://localhost/api/research/metrics?artist=Drake&source=spotify"), + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.stats).toEqual([{ source: "spotify", data: { streams_total: 100 } }]); + expect(kickRefreshResearchCacheWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + cacheKey: expect.any(String), + path: "/artist/artist_1/stat/spotify", + provider: "songstats", + }), + ); + expect(fetchResearchProvider).not.toHaveBeenCalled(); + }); + + it("returns 202 and queues a background refresh on cold miss", async () => { + vi.mocked(selectResearchCacheEntry).mockResolvedValue(null as never); + + const res = await getResearchMetricsHandler( + request("http://localhost/api/research/metrics?id=artist_1&source=spotify"), + ); + const body = await res.json(); + + expect(res.status).toBe(202); + expect(body).toEqual({ + status: "pending", + state: "refresh_pending", + message: "Research metrics refresh is pending. Retry this endpoint shortly.", + }); + expect(resolveArtist).not.toHaveBeenCalled(); + expect(kickRefreshResearchCacheWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + cacheKey: expect.any(String), + entityId: "artist_1", + path: "/artist/artist_1/stat/spotify", + source: "spotify", + }), + ); + expect(recordCreditDeduction).not.toHaveBeenCalled(); + }); + + it("uses direct metrics fetches for Chartmetric legacy provider mode", async () => { + vi.stubEnv("RESEARCH_PROVIDER", "chartmetric"); + vi.mocked(fetchResearchProvider).mockResolvedValue({ + status: 200, + data: { followers: [{ value: 100 }] }, + } as never); + + const res = await getResearchMetricsHandler( + request("http://localhost/api/research/metrics?artist=Drake&source=spotify"), + ); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ + status: "success", + followers: [{ value: 100 }], + }); + expect(selectResearchCacheEntry).not.toHaveBeenCalled(); + expect(kickRefreshResearchCacheWorkflow).not.toHaveBeenCalled(); + expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/artist_1/stat/spotify", undefined); + }); +}); diff --git a/lib/research/__tests__/getResearchMetricsHandler.test.ts b/lib/research/__tests__/getResearchMetricsHandler.test.ts index 24525b1d6..51cd67396 100644 --- a/lib/research/__tests__/getResearchMetricsHandler.test.ts +++ b/lib/research/__tests__/getResearchMetricsHandler.test.ts @@ -24,6 +24,10 @@ vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); +vi.mock("@/lib/research/cache/getResearchMetricsWithCache", () => ({ + getResearchMetricsWithCache: vi.fn(), +})); + vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: vi.fn(), })); diff --git a/lib/research/cache/ResearchCacheRefreshInput.ts b/lib/research/cache/ResearchCacheRefreshInput.ts new file mode 100644 index 000000000..bca725283 --- /dev/null +++ b/lib/research/cache/ResearchCacheRefreshInput.ts @@ -0,0 +1,12 @@ +import type { ResearchProvider } from "@/lib/research/providers/ResearchProvider"; + +export type ResearchCacheRefreshInput = { + cacheKey: string; + provider: ResearchProvider; + endpoint: string; + entityType: string; + entityId: string; + source?: string | null; + query: Record; + path: string; +}; diff --git a/lib/research/cache/buildResearchCacheKey.ts b/lib/research/cache/buildResearchCacheKey.ts new file mode 100644 index 000000000..76266ae50 --- /dev/null +++ b/lib/research/cache/buildResearchCacheKey.ts @@ -0,0 +1,27 @@ +import { createHash } from "node:crypto"; +import type { ResearchProvider } from "@/lib/research/providers/ResearchProvider"; + +export type ResearchCacheKeyInput = { + provider: ResearchProvider; + endpoint: string; + entityType: string; + entityId: string; + source?: string | null; + query?: Record; +}; + +function stableObject(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableObject); + if (typeof value !== "object" || value === null) return value; + + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, child]) => [key, stableObject(child)]), + ); +} + +export function buildResearchCacheKey(input: ResearchCacheKeyInput): string { + const payload = JSON.stringify(stableObject(input)); + return createHash("sha256").update(payload).digest("hex"); +} diff --git a/lib/research/cache/getResearchMetricsWithCache.ts b/lib/research/cache/getResearchMetricsWithCache.ts new file mode 100644 index 000000000..e46c17f25 --- /dev/null +++ b/lib/research/cache/getResearchMetricsWithCache.ts @@ -0,0 +1,132 @@ +import type { Json } from "@/types/database.types"; +import { getResearchProvider } from "@/lib/research/providers/getResearchProvider"; +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { buildResearchCacheKey } from "@/lib/research/cache/buildResearchCacheKey"; +import type { ResearchCacheRefreshInput } from "@/lib/research/cache/ResearchCacheRefreshInput"; +import { RESEARCH_CACHE_REFRESH_DEDUPE_MS } from "@/lib/research/cache/researchCacheConfig"; +import { kickRefreshResearchCacheWorkflow } from "@/lib/research/cache/kickRefreshResearchCacheWorkflow"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; +import { selectResearchCacheEntry } from "@/lib/supabase/research_cache_entries/selectResearchCacheEntry"; +import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; +import type { Tables } from "@/types/database.types"; + +type ResearchCacheEntry = Tables<"research_cache_entries">; + +export type GetResearchMetricsWithCacheParams = { + accountId: string; + artist: string; + artistId?: string; + source: string; +}; + +export type GetResearchMetricsWithCacheResult = + | { data: unknown } + | { pending: true } + | { error: string; status: number }; + +function isExpired(entry: ResearchCacheEntry, now = Date.now()): boolean { + return !entry.expires_at || Date.parse(entry.expires_at) <= now; +} + +function hasUsableData(entry: ResearchCacheEntry | null): entry is ResearchCacheEntry & { + data: Json; +} { + return entry?.data !== null && entry?.data !== undefined; +} + +function shouldStartRefresh(entry: ResearchCacheEntry | null, now = Date.now()): boolean { + if (!entry) return true; + if (!entry.refresh_started_at) return true; + return now - Date.parse(entry.refresh_started_at) > RESEARCH_CACHE_REFRESH_DEDUPE_MS; +} + +async function recordResearchRead(accountId: string): Promise { + try { + await recordCreditDeduction({ + accountId, + creditsToDeduct: 5, + source: "api", + }); + } catch (error) { + console.error("[research] credit deduction failed:", error); + } +} + +async function fetchDirectMetrics( + accountId: string, + path: string, +): Promise { + const result = await fetchResearchProvider(path, undefined); + if (result.status !== 200) { + return { error: `Request failed with status ${result.status}`, status: result.status }; + } + + await recordResearchRead(accountId); + return { data: result.data }; +} + +async function queueRefresh(input: ResearchCacheRefreshInput): Promise { + await upsertResearchCacheEntry({ + cache_key: input.cacheKey, + provider: input.provider, + endpoint: input.endpoint, + entity_type: input.entityType, + entity_id: input.entityId, + source: input.source ?? null, + query: input.query, + status: "refreshing", + refresh_started_at: new Date().toISOString(), + }); + kickRefreshResearchCacheWorkflow(input); +} + +export async function getResearchMetricsWithCache( + params: GetResearchMetricsWithCacheParams, +): Promise { + const resolved = params.artistId ? { id: params.artistId } : await resolveArtist(params.artist); + if ("error" in resolved) return { error: resolved.error, status: 404 }; + + const provider = getResearchProvider(); + const path = `/artist/${resolved.id}/stat/${params.source}`; + if (provider === "chartmetric") { + return fetchDirectMetrics(params.accountId, path); + } + + const endpoint = "artist_metrics"; + const entityType = "artist"; + const query: Record = {}; + const cacheKey = buildResearchCacheKey({ + provider, + endpoint, + entityType, + entityId: resolved.id, + source: params.source, + query, + }); + const refreshInput: ResearchCacheRefreshInput = { + cacheKey, + provider, + endpoint, + entityType, + entityId: resolved.id, + source: params.source, + query, + path, + }; + + const entry = await selectResearchCacheEntry(cacheKey); + if (hasUsableData(entry)) { + if (isExpired(entry) && shouldStartRefresh(entry)) { + await queueRefresh(refreshInput); + } + await recordResearchRead(params.accountId); + return { data: entry.data }; + } + + if (shouldStartRefresh(entry)) { + await queueRefresh(refreshInput); + } + + return { pending: true }; +} diff --git a/lib/research/cache/kickRefreshResearchCacheWorkflow.ts b/lib/research/cache/kickRefreshResearchCacheWorkflow.ts new file mode 100644 index 000000000..e4c81f0e1 --- /dev/null +++ b/lib/research/cache/kickRefreshResearchCacheWorkflow.ts @@ -0,0 +1,32 @@ +import { start } from "workflow/api"; +import { refreshResearchCacheWorkflow } from "@/app/workflows/refreshResearchCacheWorkflow"; +import type { ResearchCacheRefreshInput } from "@/lib/research/cache/ResearchCacheRefreshInput"; +import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; + +export function kickRefreshResearchCacheWorkflow(input: ResearchCacheRefreshInput): void { + void start(refreshResearchCacheWorkflow, [input]).then( + async run => { + await upsertResearchCacheEntry({ + cache_key: input.cacheKey, + provider: input.provider, + endpoint: input.endpoint, + entity_type: input.entityType, + entity_id: input.entityId, + source: input.source ?? null, + query: input.query, + status: "refreshing", + refresh_started_at: new Date().toISOString(), + refresh_run_id: run.runId, + }); + console.log( + `[research-cache] Started refresh workflow ${run.runId} for ${input.endpoint}:${input.entityId}`, + ); + }, + error => { + console.error( + `[research-cache] Failed to start refresh workflow for ${input.endpoint}:${input.entityId}:`, + error, + ); + }, + ); +} diff --git a/lib/research/cache/researchCacheConfig.ts b/lib/research/cache/researchCacheConfig.ts new file mode 100644 index 000000000..7876d9748 --- /dev/null +++ b/lib/research/cache/researchCacheConfig.ts @@ -0,0 +1,2 @@ +export const RESEARCH_METRICS_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +export const RESEARCH_CACHE_REFRESH_DEDUPE_MS = 5 * 60 * 1000; diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts index b4351d714..dc9a8f5f9 100644 --- a/lib/research/getResearchMetricsHandler.ts +++ b/lib/research/getResearchMetricsHandler.ts @@ -1,9 +1,10 @@ import { type NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { validateGetResearchMetricsRequest } from "@/lib/research/validateGetResearchMetricsRequest"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getResearchMetricsWithCache } from "@/lib/research/cache/getResearchMetricsWithCache"; /** * GET /api/research/metrics @@ -20,13 +21,20 @@ export async function getResearchMetricsHandler(request: NextRequest): Promise `/artist/${cmId}/stat/${source}`, - }); + const result = await getResearchMetricsWithCache(validated); if ("error" in result) return errorResponse(result.error, result.status); + if ("pending" in result) { + return NextResponse.json( + { + status: "pending", + state: "refresh_pending", + message: "Research metrics refresh is pending. Retry this endpoint shortly.", + }, + { status: 202, headers: getCorsHeaders() }, + ); + } + const data = result.data; const body = typeof data === "object" && data !== null && !Array.isArray(data) diff --git a/lib/supabase/research_cache_entries/selectResearchCacheEntry.ts b/lib/supabase/research_cache_entries/selectResearchCacheEntry.ts new file mode 100644 index 000000000..f1594219c --- /dev/null +++ b/lib/supabase/research_cache_entries/selectResearchCacheEntry.ts @@ -0,0 +1,19 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +export async function selectResearchCacheEntry( + cacheKey: string, +): Promise | null> { + const { data, error } = await supabase + .from("research_cache_entries") + .select("*") + .eq("cache_key", cacheKey) + .maybeSingle(); + + if (error) { + console.error("[ERROR] selectResearchCacheEntry:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/research_cache_entries/upsertResearchCacheEntry.ts b/lib/supabase/research_cache_entries/upsertResearchCacheEntry.ts new file mode 100644 index 000000000..43184fd66 --- /dev/null +++ b/lib/supabase/research_cache_entries/upsertResearchCacheEntry.ts @@ -0,0 +1,19 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +export async function upsertResearchCacheEntry( + entry: TablesInsert<"research_cache_entries">, +): Promise | null> { + const { data, error } = await supabase + .from("research_cache_entries") + .upsert(entry, { onConflict: "cache_key" }) + .select() + .single(); + + if (error) { + console.error("[ERROR] upsertResearchCacheEntry:", error); + return null; + } + + return data; +} diff --git a/types/database.types.ts b/types/database.types.ts index 191472132..4d5da2375 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -2434,6 +2434,69 @@ export type Database = { }, ]; }; + research_cache_entries: { + Row: { + cache_key: string; + created_at: string; + data: Json | null; + endpoint: string; + entity_id: string; + entity_type: string; + error: string | null; + expires_at: string | null; + fetched_at: string | null; + provider: string; + query: Json; + raw_data: Json | null; + refresh_run_id: string | null; + refresh_started_at: string | null; + source: string | null; + status: string; + status_code: number | null; + updated_at: string; + }; + Insert: { + cache_key: string; + created_at?: string; + data?: Json | null; + endpoint: string; + entity_id: string; + entity_type: string; + error?: string | null; + expires_at?: string | null; + fetched_at?: string | null; + provider: string; + query?: Json; + raw_data?: Json | null; + refresh_run_id?: string | null; + refresh_started_at?: string | null; + source?: string | null; + status?: string; + status_code?: number | null; + updated_at?: string; + }; + Update: { + cache_key?: string; + created_at?: string; + data?: Json | null; + endpoint?: string; + entity_id?: string; + entity_type?: string; + error?: string | null; + expires_at?: string | null; + fetched_at?: string | null; + provider?: string; + query?: Json; + raw_data?: Json | null; + refresh_run_id?: string | null; + refresh_started_at?: string | null; + source?: string | null; + status?: string; + status_code?: number | null; + updated_at?: string; + }; + Relationships: []; + }; role_permissions: { Row: { id: number; From 5436658e15eb5d8c50892874891d10c09a95fe90 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 3 Jun 2026 21:30:26 -0500 Subject: [PATCH 11/21] refactor: drop research metrics cache, fetch SongStats directly (KISS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing database#29 (research_cache_entries) as premature optimization — we haven't validated the bare-minimum SongStats integration yet, so a persistent cache + background-refresh workflow is over-engineering. Serve /research/metrics synchronously from the provider for now. - new lib/research/getResearchMetrics.ts: resolve artist -> fetch provider -> deduct credits -> return (no cache, no 202 pending state) - getResearchMetricsHandler: call it directly; drop the pending branch - delete the cache + Vercel Workflow cluster (lib/research/cache/*, app/workflows/refreshResearchCache*, lib/supabase/research_cache_entries/*) and the research_cache_entries type - route keeps maxDuration=60 for slow SongStats stat calls Revivable from history (+ closed database#29) if metrics latency/volume ever warrants a cache. tsc: no new errors; 195 research tests pass; lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../refreshResearchCacheStep.test.ts | 83 -------- app/workflows/refreshResearchCacheStep.ts | 61 ------ app/workflows/refreshResearchCacheWorkflow.ts | 10 - .../getResearchMetricsCacheHandler.test.ts | 187 ------------------ .../getResearchMetricsHandler.test.ts | 4 +- .../cache/ResearchCacheRefreshInput.ts | 12 -- lib/research/cache/buildResearchCacheKey.ts | 27 --- .../cache/getResearchMetricsWithCache.ts | 132 ------------- .../cache/kickRefreshResearchCacheWorkflow.ts | 32 --- lib/research/cache/researchCacheConfig.ts | 2 - lib/research/getResearchMetrics.ts | 40 ++++ lib/research/getResearchMetricsHandler.ts | 16 +- .../selectResearchCacheEntry.ts | 19 -- .../upsertResearchCacheEntry.ts | 19 -- types/database.types.ts | 63 ------ 15 files changed, 44 insertions(+), 663 deletions(-) delete mode 100644 app/workflows/__tests__/refreshResearchCacheStep.test.ts delete mode 100644 app/workflows/refreshResearchCacheStep.ts delete mode 100644 app/workflows/refreshResearchCacheWorkflow.ts delete mode 100644 lib/research/__tests__/getResearchMetricsCacheHandler.test.ts delete mode 100644 lib/research/cache/ResearchCacheRefreshInput.ts delete mode 100644 lib/research/cache/buildResearchCacheKey.ts delete mode 100644 lib/research/cache/getResearchMetricsWithCache.ts delete mode 100644 lib/research/cache/kickRefreshResearchCacheWorkflow.ts delete mode 100644 lib/research/cache/researchCacheConfig.ts create mode 100644 lib/research/getResearchMetrics.ts delete mode 100644 lib/supabase/research_cache_entries/selectResearchCacheEntry.ts delete mode 100644 lib/supabase/research_cache_entries/upsertResearchCacheEntry.ts diff --git a/app/workflows/__tests__/refreshResearchCacheStep.test.ts b/app/workflows/__tests__/refreshResearchCacheStep.test.ts deleted file mode 100644 index 01e4987cc..000000000 --- a/app/workflows/__tests__/refreshResearchCacheStep.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -import { refreshResearchCacheStep } from "../refreshResearchCacheStep"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; -import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; - -vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ - fetchResearchProvider: vi.fn(), -})); - -vi.mock("@/lib/supabase/research_cache_entries/upsertResearchCacheEntry", () => ({ - upsertResearchCacheEntry: vi.fn(), -})); - -describe("refreshResearchCacheStep", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, "log").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - }); - - it("stores successful provider responses as ready cache entries", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ - status: 200, - data: { stats: [{ source: "spotify" }] }, - }); - - await refreshResearchCacheStep({ - cacheKey: "cache_1", - provider: "songstats", - endpoint: "artist_metrics", - entityType: "artist", - entityId: "artist_1", - source: "spotify", - query: {}, - path: "/artist/artist_1/stat/spotify", - }); - - expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/artist_1/stat/spotify", {}); - expect(upsertResearchCacheEntry).toHaveBeenCalledWith( - expect.objectContaining({ - cache_key: "cache_1", - provider: "songstats", - endpoint: "artist_metrics", - entity_type: "artist", - entity_id: "artist_1", - source: "spotify", - data: { stats: [{ source: "spotify" }] }, - raw_data: { stats: [{ source: "spotify" }] }, - status: "ready", - status_code: 200, - error: null, - refresh_started_at: null, - }), - ); - }); - - it("records failed refreshes without clearing cached data", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 504, data: null }); - - await refreshResearchCacheStep({ - cacheKey: "cache_1", - provider: "songstats", - endpoint: "artist_metrics", - entityType: "artist", - entityId: "artist_1", - source: "spotify", - query: {}, - path: "/artist/artist_1/stat/spotify", - }); - - expect(upsertResearchCacheEntry).toHaveBeenCalledWith( - expect.objectContaining({ - cache_key: "cache_1", - status: "failed", - status_code: 504, - error: "Request failed with status 504", - refresh_started_at: null, - }), - ); - expect(vi.mocked(upsertResearchCacheEntry).mock.calls[0][0]).not.toHaveProperty("data"); - }); -}); diff --git a/app/workflows/refreshResearchCacheStep.ts b/app/workflows/refreshResearchCacheStep.ts deleted file mode 100644 index 5ceb1cdd6..000000000 --- a/app/workflows/refreshResearchCacheStep.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Json } from "@/types/database.types"; -import type { ResearchCacheRefreshInput } from "@/lib/research/cache/ResearchCacheRefreshInput"; -import { RESEARCH_METRICS_CACHE_TTL_MS } from "@/lib/research/cache/researchCacheConfig"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; -import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; - -function toJson(value: unknown): Json { - return JSON.parse(JSON.stringify(value ?? null)) as Json; -} - -function expiresAtForEndpoint(endpoint: string, now: Date): string { - const ttl = endpoint === "artist_metrics" ? RESEARCH_METRICS_CACHE_TTL_MS : 60 * 60 * 1000; - return new Date(now.getTime() + ttl).toISOString(); -} - -export async function refreshResearchCacheStep(input: ResearchCacheRefreshInput): Promise { - "use step"; - - console.log(`[research-cache] Refreshing ${input.endpoint}:${input.entityId}`); - const result = await fetchResearchProvider(input.path, input.query); - const now = new Date(); - - if (result.status === 200) { - await upsertResearchCacheEntry({ - cache_key: input.cacheKey, - provider: input.provider, - endpoint: input.endpoint, - entity_type: input.entityType, - entity_id: input.entityId, - source: input.source ?? null, - query: input.query, - data: toJson(result.data), - raw_data: toJson(result.data), - status: "ready", - status_code: result.status, - error: null, - fetched_at: now.toISOString(), - expires_at: expiresAtForEndpoint(input.endpoint, now), - refresh_started_at: null, - }); - console.log(`[research-cache] Refreshed ${input.endpoint}:${input.entityId}`); - return; - } - - await upsertResearchCacheEntry({ - cache_key: input.cacheKey, - provider: input.provider, - endpoint: input.endpoint, - entity_type: input.entityType, - entity_id: input.entityId, - source: input.source ?? null, - query: input.query, - status: "failed", - status_code: result.status, - error: `Request failed with status ${result.status}`, - refresh_started_at: null, - }); - console.warn( - `[research-cache] Refresh failed for ${input.endpoint}:${input.entityId} status=${result.status}`, - ); -} diff --git a/app/workflows/refreshResearchCacheWorkflow.ts b/app/workflows/refreshResearchCacheWorkflow.ts deleted file mode 100644 index 11ebf0534..000000000 --- a/app/workflows/refreshResearchCacheWorkflow.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ResearchCacheRefreshInput } from "@/lib/research/cache/ResearchCacheRefreshInput"; -import { refreshResearchCacheStep } from "@/app/workflows/refreshResearchCacheStep"; - -export async function refreshResearchCacheWorkflow(input: ResearchCacheRefreshInput) { - "use workflow"; - - console.log(`[research-cache] workflow:start ${input.endpoint}:${input.entityId}`); - await refreshResearchCacheStep(input); - console.log(`[research-cache] workflow:done ${input.endpoint}:${input.entityId}`); -} diff --git a/lib/research/__tests__/getResearchMetricsCacheHandler.test.ts b/lib/research/__tests__/getResearchMetricsCacheHandler.test.ts deleted file mode 100644 index 41d3ab3c4..000000000 --- a/lib/research/__tests__/getResearchMetricsCacheHandler.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NextRequest } from "next/server"; - -import { getResearchMetricsHandler } from "../getResearchMetricsHandler"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { selectResearchCacheEntry } from "@/lib/supabase/research_cache_entries/selectResearchCacheEntry"; -import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; -import { kickRefreshResearchCacheWorkflow } from "@/lib/research/cache/kickRefreshResearchCacheWorkflow"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; -import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -vi.mock("@/lib/research/ensureResearchCredits", () => ({ - ensureResearchCredits: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/research/resolveArtist", () => ({ - resolveArtist: vi.fn(), -})); - -vi.mock("@/lib/supabase/research_cache_entries/selectResearchCacheEntry", () => ({ - selectResearchCacheEntry: vi.fn(), -})); - -vi.mock("@/lib/supabase/research_cache_entries/upsertResearchCacheEntry", () => ({ - upsertResearchCacheEntry: vi.fn(), -})); - -vi.mock("@/lib/research/cache/kickRefreshResearchCacheWorkflow", () => ({ - kickRefreshResearchCacheWorkflow: vi.fn(), -})); - -vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ - fetchResearchProvider: vi.fn(), -})); - -vi.mock("@/lib/credits/recordCreditDeduction", () => ({ - recordCreditDeduction: vi.fn(), -})); - -function request(url: string) { - return new NextRequest(url, { headers: { "x-api-key": "test" } }); -} - -function cacheRow(overrides: Record = {}) { - return { - cache_key: "cache_1", - provider: "songstats", - endpoint: "artist_metrics", - entity_type: "artist", - entity_id: "artist_1", - source: "spotify", - query: {}, - data: { stats: [{ source: "spotify", data: { streams_total: 100 } }] }, - raw_data: { stats: [{ source: "spotify", data: { streams_total: 100 } }] }, - status: "ready", - status_code: 200, - error: null, - fetched_at: new Date().toISOString(), - expires_at: new Date(Date.now() + 60_000).toISOString(), - refresh_started_at: null, - refresh_run_id: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - ...overrides, - }; -} - -describe("getResearchMetricsHandler cache behavior", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "account_1", - orgId: null, - authToken: "token", - }); - vi.mocked(resolveArtist).mockResolvedValue({ id: "artist_1" }); - vi.mocked(recordCreditDeduction).mockResolvedValue(undefined as never); - vi.mocked(upsertResearchCacheEntry).mockResolvedValue(null as never); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("returns fresh cached metrics without calling the provider", async () => { - vi.mocked(selectResearchCacheEntry).mockResolvedValue(cacheRow() as never); - - const res = await getResearchMetricsHandler( - request("http://localhost/api/research/metrics?artist=Drake&source=spotify"), - ); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body).toEqual({ - status: "success", - stats: [{ source: "spotify", data: { streams_total: 100 } }], - }); - expect(fetchResearchProvider).not.toHaveBeenCalled(); - expect(kickRefreshResearchCacheWorkflow).not.toHaveBeenCalled(); - expect(recordCreditDeduction).toHaveBeenCalledWith({ - accountId: "account_1", - creditsToDeduct: 5, - source: "api", - }); - }); - - it("returns stale cached metrics and queues a background refresh", async () => { - vi.mocked(selectResearchCacheEntry).mockResolvedValue( - cacheRow({ - expires_at: new Date(Date.now() - 60_000).toISOString(), - }) as never, - ); - - const res = await getResearchMetricsHandler( - request("http://localhost/api/research/metrics?artist=Drake&source=spotify"), - ); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.stats).toEqual([{ source: "spotify", data: { streams_total: 100 } }]); - expect(kickRefreshResearchCacheWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - cacheKey: expect.any(String), - path: "/artist/artist_1/stat/spotify", - provider: "songstats", - }), - ); - expect(fetchResearchProvider).not.toHaveBeenCalled(); - }); - - it("returns 202 and queues a background refresh on cold miss", async () => { - vi.mocked(selectResearchCacheEntry).mockResolvedValue(null as never); - - const res = await getResearchMetricsHandler( - request("http://localhost/api/research/metrics?id=artist_1&source=spotify"), - ); - const body = await res.json(); - - expect(res.status).toBe(202); - expect(body).toEqual({ - status: "pending", - state: "refresh_pending", - message: "Research metrics refresh is pending. Retry this endpoint shortly.", - }); - expect(resolveArtist).not.toHaveBeenCalled(); - expect(kickRefreshResearchCacheWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - cacheKey: expect.any(String), - entityId: "artist_1", - path: "/artist/artist_1/stat/spotify", - source: "spotify", - }), - ); - expect(recordCreditDeduction).not.toHaveBeenCalled(); - }); - - it("uses direct metrics fetches for Chartmetric legacy provider mode", async () => { - vi.stubEnv("RESEARCH_PROVIDER", "chartmetric"); - vi.mocked(fetchResearchProvider).mockResolvedValue({ - status: 200, - data: { followers: [{ value: 100 }] }, - } as never); - - const res = await getResearchMetricsHandler( - request("http://localhost/api/research/metrics?artist=Drake&source=spotify"), - ); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body).toEqual({ - status: "success", - followers: [{ value: 100 }], - }); - expect(selectResearchCacheEntry).not.toHaveBeenCalled(); - expect(kickRefreshResearchCacheWorkflow).not.toHaveBeenCalled(); - expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/artist_1/stat/spotify", undefined); - }); -}); diff --git a/lib/research/__tests__/getResearchMetricsHandler.test.ts b/lib/research/__tests__/getResearchMetricsHandler.test.ts index 51cd67396..b1dec919c 100644 --- a/lib/research/__tests__/getResearchMetricsHandler.test.ts +++ b/lib/research/__tests__/getResearchMetricsHandler.test.ts @@ -24,8 +24,8 @@ vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ fetchChartmetric: vi.fn(), })); -vi.mock("@/lib/research/cache/getResearchMetricsWithCache", () => ({ - getResearchMetricsWithCache: vi.fn(), +vi.mock("@/lib/research/getResearchMetrics", () => ({ + getResearchMetrics: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ diff --git a/lib/research/cache/ResearchCacheRefreshInput.ts b/lib/research/cache/ResearchCacheRefreshInput.ts deleted file mode 100644 index bca725283..000000000 --- a/lib/research/cache/ResearchCacheRefreshInput.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ResearchProvider } from "@/lib/research/providers/ResearchProvider"; - -export type ResearchCacheRefreshInput = { - cacheKey: string; - provider: ResearchProvider; - endpoint: string; - entityType: string; - entityId: string; - source?: string | null; - query: Record; - path: string; -}; diff --git a/lib/research/cache/buildResearchCacheKey.ts b/lib/research/cache/buildResearchCacheKey.ts deleted file mode 100644 index 76266ae50..000000000 --- a/lib/research/cache/buildResearchCacheKey.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createHash } from "node:crypto"; -import type { ResearchProvider } from "@/lib/research/providers/ResearchProvider"; - -export type ResearchCacheKeyInput = { - provider: ResearchProvider; - endpoint: string; - entityType: string; - entityId: string; - source?: string | null; - query?: Record; -}; - -function stableObject(value: unknown): unknown { - if (Array.isArray(value)) return value.map(stableObject); - if (typeof value !== "object" || value === null) return value; - - return Object.fromEntries( - Object.entries(value as Record) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, child]) => [key, stableObject(child)]), - ); -} - -export function buildResearchCacheKey(input: ResearchCacheKeyInput): string { - const payload = JSON.stringify(stableObject(input)); - return createHash("sha256").update(payload).digest("hex"); -} diff --git a/lib/research/cache/getResearchMetricsWithCache.ts b/lib/research/cache/getResearchMetricsWithCache.ts deleted file mode 100644 index e46c17f25..000000000 --- a/lib/research/cache/getResearchMetricsWithCache.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { Json } from "@/types/database.types"; -import { getResearchProvider } from "@/lib/research/providers/getResearchProvider"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; -import { buildResearchCacheKey } from "@/lib/research/cache/buildResearchCacheKey"; -import type { ResearchCacheRefreshInput } from "@/lib/research/cache/ResearchCacheRefreshInput"; -import { RESEARCH_CACHE_REFRESH_DEDUPE_MS } from "@/lib/research/cache/researchCacheConfig"; -import { kickRefreshResearchCacheWorkflow } from "@/lib/research/cache/kickRefreshResearchCacheWorkflow"; -import { resolveArtist } from "@/lib/research/resolveArtist"; -import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; -import { selectResearchCacheEntry } from "@/lib/supabase/research_cache_entries/selectResearchCacheEntry"; -import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; -import type { Tables } from "@/types/database.types"; - -type ResearchCacheEntry = Tables<"research_cache_entries">; - -export type GetResearchMetricsWithCacheParams = { - accountId: string; - artist: string; - artistId?: string; - source: string; -}; - -export type GetResearchMetricsWithCacheResult = - | { data: unknown } - | { pending: true } - | { error: string; status: number }; - -function isExpired(entry: ResearchCacheEntry, now = Date.now()): boolean { - return !entry.expires_at || Date.parse(entry.expires_at) <= now; -} - -function hasUsableData(entry: ResearchCacheEntry | null): entry is ResearchCacheEntry & { - data: Json; -} { - return entry?.data !== null && entry?.data !== undefined; -} - -function shouldStartRefresh(entry: ResearchCacheEntry | null, now = Date.now()): boolean { - if (!entry) return true; - if (!entry.refresh_started_at) return true; - return now - Date.parse(entry.refresh_started_at) > RESEARCH_CACHE_REFRESH_DEDUPE_MS; -} - -async function recordResearchRead(accountId: string): Promise { - try { - await recordCreditDeduction({ - accountId, - creditsToDeduct: 5, - source: "api", - }); - } catch (error) { - console.error("[research] credit deduction failed:", error); - } -} - -async function fetchDirectMetrics( - accountId: string, - path: string, -): Promise { - const result = await fetchResearchProvider(path, undefined); - if (result.status !== 200) { - return { error: `Request failed with status ${result.status}`, status: result.status }; - } - - await recordResearchRead(accountId); - return { data: result.data }; -} - -async function queueRefresh(input: ResearchCacheRefreshInput): Promise { - await upsertResearchCacheEntry({ - cache_key: input.cacheKey, - provider: input.provider, - endpoint: input.endpoint, - entity_type: input.entityType, - entity_id: input.entityId, - source: input.source ?? null, - query: input.query, - status: "refreshing", - refresh_started_at: new Date().toISOString(), - }); - kickRefreshResearchCacheWorkflow(input); -} - -export async function getResearchMetricsWithCache( - params: GetResearchMetricsWithCacheParams, -): Promise { - const resolved = params.artistId ? { id: params.artistId } : await resolveArtist(params.artist); - if ("error" in resolved) return { error: resolved.error, status: 404 }; - - const provider = getResearchProvider(); - const path = `/artist/${resolved.id}/stat/${params.source}`; - if (provider === "chartmetric") { - return fetchDirectMetrics(params.accountId, path); - } - - const endpoint = "artist_metrics"; - const entityType = "artist"; - const query: Record = {}; - const cacheKey = buildResearchCacheKey({ - provider, - endpoint, - entityType, - entityId: resolved.id, - source: params.source, - query, - }); - const refreshInput: ResearchCacheRefreshInput = { - cacheKey, - provider, - endpoint, - entityType, - entityId: resolved.id, - source: params.source, - query, - path, - }; - - const entry = await selectResearchCacheEntry(cacheKey); - if (hasUsableData(entry)) { - if (isExpired(entry) && shouldStartRefresh(entry)) { - await queueRefresh(refreshInput); - } - await recordResearchRead(params.accountId); - return { data: entry.data }; - } - - if (shouldStartRefresh(entry)) { - await queueRefresh(refreshInput); - } - - return { pending: true }; -} diff --git a/lib/research/cache/kickRefreshResearchCacheWorkflow.ts b/lib/research/cache/kickRefreshResearchCacheWorkflow.ts deleted file mode 100644 index e4c81f0e1..000000000 --- a/lib/research/cache/kickRefreshResearchCacheWorkflow.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { start } from "workflow/api"; -import { refreshResearchCacheWorkflow } from "@/app/workflows/refreshResearchCacheWorkflow"; -import type { ResearchCacheRefreshInput } from "@/lib/research/cache/ResearchCacheRefreshInput"; -import { upsertResearchCacheEntry } from "@/lib/supabase/research_cache_entries/upsertResearchCacheEntry"; - -export function kickRefreshResearchCacheWorkflow(input: ResearchCacheRefreshInput): void { - void start(refreshResearchCacheWorkflow, [input]).then( - async run => { - await upsertResearchCacheEntry({ - cache_key: input.cacheKey, - provider: input.provider, - endpoint: input.endpoint, - entity_type: input.entityType, - entity_id: input.entityId, - source: input.source ?? null, - query: input.query, - status: "refreshing", - refresh_started_at: new Date().toISOString(), - refresh_run_id: run.runId, - }); - console.log( - `[research-cache] Started refresh workflow ${run.runId} for ${input.endpoint}:${input.entityId}`, - ); - }, - error => { - console.error( - `[research-cache] Failed to start refresh workflow for ${input.endpoint}:${input.entityId}:`, - error, - ); - }, - ); -} diff --git a/lib/research/cache/researchCacheConfig.ts b/lib/research/cache/researchCacheConfig.ts deleted file mode 100644 index 7876d9748..000000000 --- a/lib/research/cache/researchCacheConfig.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const RESEARCH_METRICS_CACHE_TTL_MS = 6 * 60 * 60 * 1000; -export const RESEARCH_CACHE_REFRESH_DEDUPE_MS = 5 * 60 * 1000; diff --git a/lib/research/getResearchMetrics.ts b/lib/research/getResearchMetrics.ts new file mode 100644 index 000000000..5ee2c14a8 --- /dev/null +++ b/lib/research/getResearchMetrics.ts @@ -0,0 +1,40 @@ +import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { resolveArtist } from "@/lib/research/resolveArtist"; +import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; + +export type GetResearchMetricsParams = { + accountId: string; + artist: string; + artistId?: string; + source: string; +}; + +export type GetResearchMetricsResult = { data: unknown } | { error: string; status: number }; + +/** + * Fetches platform-specific artist metrics directly from the research provider. + * + * KISS: no caching layer — the request is served synchronously from the provider. + * If provider latency / request volume becomes a problem once the integration is + * proven, reintroduce a cache (see closed database#29 + chat#1777). + */ +export async function getResearchMetrics( + params: GetResearchMetricsParams, +): Promise { + const resolved = params.artistId ? { id: params.artistId } : await resolveArtist(params.artist); + if ("error" in resolved) return { error: resolved.error, status: 404 }; + + const path = `/artist/${resolved.id}/stat/${params.source}`; + const result = await fetchResearchProvider(path, undefined); + if (result.status !== 200) { + return { error: `Request failed with status ${result.status}`, status: result.status }; + } + + try { + await recordCreditDeduction({ accountId: params.accountId, creditsToDeduct: 5, source: "api" }); + } catch (error) { + console.error("[research] credit deduction failed:", error); + } + + return { data: result.data }; +} diff --git a/lib/research/getResearchMetricsHandler.ts b/lib/research/getResearchMetricsHandler.ts index dc9a8f5f9..28fdb1279 100644 --- a/lib/research/getResearchMetricsHandler.ts +++ b/lib/research/getResearchMetricsHandler.ts @@ -3,8 +3,7 @@ import { NextResponse } from "next/server"; import { validateGetResearchMetricsRequest } from "@/lib/research/validateGetResearchMetricsRequest"; import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchMetricsWithCache } from "@/lib/research/cache/getResearchMetricsWithCache"; +import { getResearchMetrics } from "@/lib/research/getResearchMetrics"; /** * GET /api/research/metrics @@ -21,19 +20,8 @@ export async function getResearchMetricsHandler(request: NextRequest): Promise | null> { - const { data, error } = await supabase - .from("research_cache_entries") - .select("*") - .eq("cache_key", cacheKey) - .maybeSingle(); - - if (error) { - console.error("[ERROR] selectResearchCacheEntry:", error); - return null; - } - - return data; -} diff --git a/lib/supabase/research_cache_entries/upsertResearchCacheEntry.ts b/lib/supabase/research_cache_entries/upsertResearchCacheEntry.ts deleted file mode 100644 index 43184fd66..000000000 --- a/lib/supabase/research_cache_entries/upsertResearchCacheEntry.ts +++ /dev/null @@ -1,19 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; -import type { Tables, TablesInsert } from "@/types/database.types"; - -export async function upsertResearchCacheEntry( - entry: TablesInsert<"research_cache_entries">, -): Promise | null> { - const { data, error } = await supabase - .from("research_cache_entries") - .upsert(entry, { onConflict: "cache_key" }) - .select() - .single(); - - if (error) { - console.error("[ERROR] upsertResearchCacheEntry:", error); - return null; - } - - return data; -} diff --git a/types/database.types.ts b/types/database.types.ts index 4d5da2375..191472132 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -2434,69 +2434,6 @@ export type Database = { }, ]; }; - research_cache_entries: { - Row: { - cache_key: string; - created_at: string; - data: Json | null; - endpoint: string; - entity_id: string; - entity_type: string; - error: string | null; - expires_at: string | null; - fetched_at: string | null; - provider: string; - query: Json; - raw_data: Json | null; - refresh_run_id: string | null; - refresh_started_at: string | null; - source: string | null; - status: string; - status_code: number | null; - updated_at: string; - }; - Insert: { - cache_key: string; - created_at?: string; - data?: Json | null; - endpoint: string; - entity_id: string; - entity_type: string; - error?: string | null; - expires_at?: string | null; - fetched_at?: string | null; - provider: string; - query?: Json; - raw_data?: Json | null; - refresh_run_id?: string | null; - refresh_started_at?: string | null; - source?: string | null; - status?: string; - status_code?: number | null; - updated_at?: string; - }; - Update: { - cache_key?: string; - created_at?: string; - data?: Json | null; - endpoint?: string; - entity_id?: string; - entity_type?: string; - error?: string | null; - expires_at?: string | null; - fetched_at?: string | null; - provider?: string; - query?: Json; - raw_data?: Json | null; - refresh_run_id?: string | null; - refresh_started_at?: string | null; - source?: string | null; - status?: string; - status_code?: number | null; - updated_at?: string; - }; - Relationships: []; - }; role_permissions: { Row: { id: number; From b604f4b063b251010018f70c95728ce128942fa4 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 4 Jun 2026 19:08:10 -0700 Subject: [PATCH 12/21] refactor: remove Chartmetric integration and related files This commit eliminates the Chartmetric integration, including its API calls, token management, and associated tests. The environment configuration for Chartmetric has also been cleaned up. The focus is now solely on using SongStats for research data retrieval, simplifying the codebase and reducing complexity. - Deleted Chartmetric-related files: chartmetricBase.ts, chartmetricTokenCache.ts, fetchChartmetric.ts, getChartmetricToken.ts, resetTokenCache.ts, and their tests. - Updated .env.example to remove Chartmetric configuration. - Adjusted fetchResearchProvider to exclusively use SongStats. No new errors introduced; existing tests remain intact. --- .env.example | 5 - .../__tests__/fetchChartmetric.test.ts | 93 ------------------- .../__tests__/getChartmetricToken.test.ts | 78 ---------------- .../__tests__/resetTokenCache.test.ts | 39 -------- lib/chartmetric/chartmetricBase.ts | 2 - lib/chartmetric/chartmetricTokenCache.ts | 14 --- lib/chartmetric/fetchChartmetric.ts | 52 ----------- lib/chartmetric/getChartmetricToken.ts | 50 ---------- lib/chartmetric/resetTokenCache.ts | 12 --- .../getResearchMetricsHandler.test.ts | 4 - lib/research/__tests__/resolveTrack.test.ts | 12 +-- .../getResearchInstagramPostsHandler.ts | 3 +- lib/research/providers/ResearchProvider.ts | 1 - .../__tests__/fetchResearchProvider.test.ts | 28 +----- .../__tests__/getResearchProvider.test.ts | 29 ------ .../providers/fetchResearchProvider.ts | 7 +- lib/research/providers/getResearchProvider.ts | 7 -- lib/research/resolveTrack.ts | 6 +- 18 files changed, 11 insertions(+), 431 deletions(-) delete mode 100644 lib/chartmetric/__tests__/fetchChartmetric.test.ts delete mode 100644 lib/chartmetric/__tests__/getChartmetricToken.test.ts delete mode 100644 lib/chartmetric/__tests__/resetTokenCache.test.ts delete mode 100644 lib/chartmetric/chartmetricBase.ts delete mode 100644 lib/chartmetric/chartmetricTokenCache.ts delete mode 100644 lib/chartmetric/fetchChartmetric.ts delete mode 100644 lib/chartmetric/getChartmetricToken.ts delete mode 100644 lib/chartmetric/resetTokenCache.ts delete mode 100644 lib/research/providers/ResearchProvider.ts delete mode 100644 lib/research/providers/__tests__/getResearchProvider.test.ts delete mode 100644 lib/research/providers/getResearchProvider.ts diff --git a/.env.example b/.env.example index a60daf92a..6cc35f631 100644 --- a/.env.example +++ b/.env.example @@ -21,11 +21,6 @@ SONGSTATS_API_KEY= # Legacy alias also supported: SongStats_API= -# Research provider override. Defaults to SongStats when unset. -# Set RESEARCH_PROVIDER=chartmetric only for legacy/customer-provided Chartmetric access. -RESEARCH_PROVIDER= -CHARTMETRIC_REFRESH_TOKEN= - # Spotify SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= diff --git a/lib/chartmetric/__tests__/fetchChartmetric.test.ts b/lib/chartmetric/__tests__/fetchChartmetric.test.ts deleted file mode 100644 index 06bdb8571..000000000 --- a/lib/chartmetric/__tests__/fetchChartmetric.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; - -vi.mock("@/lib/chartmetric/getChartmetricToken", () => ({ - getChartmetricToken: vi.fn().mockResolvedValue("mock-token"), -})); - -const mockFetch = vi.fn(); - -describe("fetchChartmetric", () => { - 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 fetchChartmetric("/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 fetchChartmetric("/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 fetchChartmetric("/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 fetchChartmetric("/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 fetchChartmetric("/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 fetchChartmetric("/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/chartmetric/__tests__/getChartmetricToken.test.ts b/lib/chartmetric/__tests__/getChartmetricToken.test.ts deleted file mode 100644 index bcc7a88af..000000000 --- a/lib/chartmetric/__tests__/getChartmetricToken.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getChartmetricToken } from "../getChartmetricToken"; -import { resetTokenCache } from "../resetTokenCache"; - -describe("getChartmetricToken", () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); - resetTokenCache(); - process.env = { ...originalEnv }; - }); - - it("throws when CHARTMETRIC_REFRESH_TOKEN is not set", async () => { - delete process.env.CHARTMETRIC_REFRESH_TOKEN; - - await expect(getChartmetricToken()).rejects.toThrow("CHARTMETRIC_REFRESH_TOKEN"); - }); - - it("returns token on successful exchange", async () => { - process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; - - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => ({ token: "test-access-token", expires_in: 3600 }), - } as Response); - - const token = await getChartmetricToken(); - - expect(token).toBe("test-access-token"); - expect(fetch).toHaveBeenCalledWith( - "https://api.chartmetric.com/api/token", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ refreshtoken: "test-refresh-token" }), - }), - ); - }); - - it("throws when token exchange returns non-ok response", async () => { - process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; - - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: false, - status: 401, - } as Response); - - await expect(getChartmetricToken()).rejects.toThrow("401"); - }); - - it("caches the token and does not fetch again on second call", async () => { - process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => ({ token: "cached-token", expires_in: 3600 }), - } as Response); - - const token1 = await getChartmetricToken(); - const token2 = await getChartmetricToken(); - - expect(token1).toBe("cached-token"); - expect(token2).toBe("cached-token"); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it("throws when response has no token", async () => { - process.env.CHARTMETRIC_REFRESH_TOKEN = "test-refresh-token"; - - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => ({ expires_in: 3600 }), - } as Response); - - await expect(getChartmetricToken()).rejects.toThrow("token"); - }); -}); diff --git a/lib/chartmetric/__tests__/resetTokenCache.test.ts b/lib/chartmetric/__tests__/resetTokenCache.test.ts deleted file mode 100644 index ad1aa41eb..000000000 --- a/lib/chartmetric/__tests__/resetTokenCache.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -describe("resetTokenCache", () => { - const OLD_ENV = process.env; - - beforeEach(() => { - vi.restoreAllMocks(); - process.env = { ...OLD_ENV, CHARTMETRIC_REFRESH_TOKEN: "refresh-abc" }; - }); - - afterEach(() => { - process.env = OLD_ENV; - }); - - it("is exported from its own module file", async () => { - const mod = await import("../resetTokenCache"); - expect(typeof mod.resetTokenCache).toBe("function"); - }); - - it("clears the cached token so the next getChartmetricToken refetches", async () => { - const { getChartmetricToken } = await import("../getChartmetricToken"); - const { resetTokenCache } = await import("../resetTokenCache"); - - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ token: "t1", expires_in: 3600 }), - }); - vi.stubGlobal("fetch", fetchMock); - - resetTokenCache(); - await getChartmetricToken(); - await getChartmetricToken(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - resetTokenCache(); - await getChartmetricToken(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); -}); diff --git a/lib/chartmetric/chartmetricBase.ts b/lib/chartmetric/chartmetricBase.ts deleted file mode 100644 index b86e92986..000000000 --- a/lib/chartmetric/chartmetricBase.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Base URL for the Chartmetric REST API. Shared by token exchange and proxy fetches. */ -export const CHARTMETRIC_BASE = "https://api.chartmetric.com/api"; diff --git a/lib/chartmetric/chartmetricTokenCache.ts b/lib/chartmetric/chartmetricTokenCache.ts deleted file mode 100644 index a3b518f0f..000000000 --- a/lib/chartmetric/chartmetricTokenCache.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Shared in-memory cache for the short-lived Chartmetric access token. - * - * Isolated in its own module so both `getChartmetricToken` (writer/reader) and - * `resetTokenCache` (test-only clear) can share state without either file - * owning both responsibilities. - */ -export const chartmetricTokenCache: { - token: string | null; - expiresAt: number; -} = { - token: null, - expiresAt: 0, -}; diff --git a/lib/chartmetric/fetchChartmetric.ts b/lib/chartmetric/fetchChartmetric.ts deleted file mode 100644 index 42e6eab50..000000000 --- a/lib/chartmetric/fetchChartmetric.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken"; -import { CHARTMETRIC_BASE } from "@/lib/chartmetric/chartmetricBase"; -import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; - -/** - * 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 fetchChartmetric( - path: string, - queryParams?: Record, -): Promise { - try { - const accessToken = await getChartmetricToken(); - - const url = new URL(`${CHARTMETRIC_BASE}${path}`); - if (queryParams) { - for (const [key, value] of Object.entries(queryParams)) { - if (value !== undefined && value !== "") { - url.searchParams.set(key, value); - } - } - } - - const response = await fetch(url.toString(), { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - return { - data: { error: `Chartmetric API returned ${response.status}` }, - status: response.status, - }; - } - - const json = await response.json(); - - const data = json.obj !== undefined ? json.obj : json; - - return { data, status: response.status }; - } catch { - return { data: null, status: 500 }; - } -} diff --git a/lib/chartmetric/getChartmetricToken.ts b/lib/chartmetric/getChartmetricToken.ts deleted file mode 100644 index 41dc958a0..000000000 --- a/lib/chartmetric/getChartmetricToken.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { chartmetricTokenCache } from "./chartmetricTokenCache"; -import { CHARTMETRIC_BASE } from "./chartmetricBase"; - -/** - * Exchanges the Chartmetric refresh token for a short-lived access token. - * Caches the token until 60 seconds before expiry to avoid redundant API calls. - * - * @returns The Chartmetric access token string. - * @throws Error if the token exchange fails or the env variable is missing. - */ -export async function getChartmetricToken(): Promise { - if (chartmetricTokenCache.token && Date.now() < chartmetricTokenCache.expiresAt) { - return chartmetricTokenCache.token; - } - - const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN; - - if (!refreshToken) { - throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set"); - } - - const response = await fetch(`${CHARTMETRIC_BASE}/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"); - } - - chartmetricTokenCache.token = token; - chartmetricTokenCache.expiresAt = Date.now() + (data.expires_in - 60) * 1000; - - return token; -} diff --git a/lib/chartmetric/resetTokenCache.ts b/lib/chartmetric/resetTokenCache.ts deleted file mode 100644 index 59f786975..000000000 --- a/lib/chartmetric/resetTokenCache.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { chartmetricTokenCache } from "./chartmetricTokenCache"; - -/** - * Reset the cached Chartmetric access token. Test-only utility — lets tests - * observe the fetch path without carrying state across cases. - * - * @internal - */ -export function resetTokenCache(): void { - chartmetricTokenCache.token = null; - chartmetricTokenCache.expiresAt = 0; -} diff --git a/lib/research/__tests__/getResearchMetricsHandler.test.ts b/lib/research/__tests__/getResearchMetricsHandler.test.ts index b1dec919c..4f4bf8b0e 100644 --- a/lib/research/__tests__/getResearchMetricsHandler.test.ts +++ b/lib/research/__tests__/getResearchMetricsHandler.test.ts @@ -20,10 +20,6 @@ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), -})); - vi.mock("@/lib/research/getResearchMetrics", () => ({ getResearchMetrics: vi.fn(), })); diff --git a/lib/research/__tests__/resolveTrack.test.ts b/lib/research/__tests__/resolveTrack.test.ts index 2b7341fd6..cb6eb661f 100644 --- a/lib/research/__tests__/resolveTrack.test.ts +++ b/lib/research/__tests__/resolveTrack.test.ts @@ -48,7 +48,7 @@ describe("resolveTrack", () => { }, } as never); vi.mocked(handleResearch).mockResolvedValue({ - data: { chartmetric_ids: [42] }, + data: { songstats_track_id: "track_42" }, }); const result = await resolveTrack("q", undefined, accountId); @@ -56,7 +56,7 @@ describe("resolveTrack", () => { accountId, path: "/track/isrc/ISRC123/get-ids", }); - expect("id" in result && result.id).toBe("42"); + expect("id" in result && result.id).toBe("track_42"); }); it("falls back to spotify-id path via handleResearch when ISRC lookup yields no id", async () => { @@ -69,14 +69,14 @@ describe("resolveTrack", () => { } as never); vi.mocked(handleResearch) .mockResolvedValueOnce({ data: {} }) - .mockResolvedValueOnce({ data: { chartmetric_ids: [99] } }); + .mockResolvedValueOnce({ data: { songstats_track_id: "track_99" } }); const result = await resolveTrack("q", undefined, accountId); expect(vi.mocked(handleResearch)).toHaveBeenNthCalledWith(2, { accountId, path: "/track/spotify/sp1/get-ids", }); - expect("id" in result && result.id).toBe("99"); + expect("id" in result && result.id).toBe("track_99"); }); it("uses spotify-id path when no ISRC is present", async () => { @@ -86,7 +86,7 @@ describe("resolveTrack", () => { }, } as never); vi.mocked(handleResearch).mockResolvedValue({ - data: { chartmetric_ids: [7] }, + data: { songstats_track_ids: ["track_7"] }, }); const result = await resolveTrack("q", undefined, accountId); @@ -94,7 +94,7 @@ describe("resolveTrack", () => { accountId, path: "/track/spotify/sp9/get-ids", }); - expect("id" in result && result.id).toBe("7"); + expect("id" in result && result.id).toBe("track_7"); }); it("returns error when neither ISRC nor spotify-id resolves", async () => { diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts index eb67567dc..68ce31cd5 100644 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ b/lib/research/getResearchInstagramPostsHandler.ts @@ -8,8 +8,7 @@ import { errorResponse } from "@/lib/networking/errorResponse"; /** * GET /api/research/instagram-posts * - * Returns recent Instagram posts for the given artist through the legacy - * DeepSocial/Chartmetric dataset when the configured provider supports it. + * Returns recent Instagram posts for the given artist when SongStats supports it. * Requires `artist` query param. * * @param request - The incoming HTTP request. diff --git a/lib/research/providers/ResearchProvider.ts b/lib/research/providers/ResearchProvider.ts deleted file mode 100644 index 5d55660d8..000000000 --- a/lib/research/providers/ResearchProvider.ts +++ /dev/null @@ -1 +0,0 @@ -export type ResearchProvider = "songstats" | "chartmetric"; diff --git a/lib/research/providers/__tests__/fetchResearchProvider.test.ts b/lib/research/providers/__tests__/fetchResearchProvider.test.ts index fb305b17f..330c7ed7b 100644 --- a/lib/research/providers/__tests__/fetchResearchProvider.test.ts +++ b/lib/research/providers/__tests__/fetchResearchProvider.test.ts @@ -1,47 +1,23 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchResearchProvider } from "../fetchResearchProvider"; -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; -vi.mock("@/lib/chartmetric/fetchChartmetric", () => ({ - fetchChartmetric: vi.fn(), -})); - vi.mock("@/lib/research/songstats/fetchSongstatsResearch", () => ({ fetchSongstatsResearch: vi.fn(), })); -const ORIGINAL_PROVIDER = process.env.RESEARCH_PROVIDER; - describe("fetchResearchProvider", () => { beforeEach(() => { vi.clearAllMocks(); }); - afterEach(() => { - process.env.RESEARCH_PROVIDER = ORIGINAL_PROVIDER; - }); - - it("uses SongStats research by default", async () => { - delete process.env.RESEARCH_PROVIDER; + it("delegates to SongStats research", async () => { vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: { ok: true } }); const result = await fetchResearchProvider("/search", { q: "Drake" }); expect(result).toEqual({ status: 200, data: { ok: true } }); expect(fetchSongstatsResearch).toHaveBeenCalledWith("/search", { q: "Drake" }); - expect(fetchChartmetric).not.toHaveBeenCalled(); - }); - - it("uses Chartmetric when configured as the legacy provider", async () => { - process.env.RESEARCH_PROVIDER = "chartmetric"; - vi.mocked(fetchChartmetric).mockResolvedValue({ status: 200, data: { legacy: true } }); - - const result = await fetchResearchProvider("/search", { q: "Drake" }); - - expect(result).toEqual({ status: 200, data: { legacy: true } }); - expect(fetchChartmetric).toHaveBeenCalledWith("/search", { q: "Drake" }); - expect(fetchSongstatsResearch).not.toHaveBeenCalled(); }); }); diff --git a/lib/research/providers/__tests__/getResearchProvider.test.ts b/lib/research/providers/__tests__/getResearchProvider.test.ts deleted file mode 100644 index 7e8d3d449..000000000 --- a/lib/research/providers/__tests__/getResearchProvider.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it, afterEach } from "vitest"; - -import { getResearchProvider } from "../getResearchProvider"; - -const ORIGINAL_PROVIDER = process.env.RESEARCH_PROVIDER; - -describe("getResearchProvider", () => { - afterEach(() => { - process.env.RESEARCH_PROVIDER = ORIGINAL_PROVIDER; - }); - - it("defaults research to SongStats", () => { - delete process.env.RESEARCH_PROVIDER; - - expect(getResearchProvider()).toBe("songstats"); - }); - - it("preserves Chartmetric when explicitly configured as the legacy provider", () => { - process.env.RESEARCH_PROVIDER = "chartmetric"; - - expect(getResearchProvider()).toBe("chartmetric"); - }); - - it("falls back to SongStats for unrecognized provider values", () => { - process.env.RESEARCH_PROVIDER = "unknown"; - - expect(getResearchProvider()).toBe("songstats"); - }); -}); diff --git a/lib/research/providers/fetchResearchProvider.ts b/lib/research/providers/fetchResearchProvider.ts index 6cdb566da..868250a4c 100644 --- a/lib/research/providers/fetchResearchProvider.ts +++ b/lib/research/providers/fetchResearchProvider.ts @@ -1,15 +1,10 @@ -import { fetchChartmetric } from "@/lib/chartmetric/fetchChartmetric"; -import { getResearchProvider } from "@/lib/research/providers/getResearchProvider"; import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +/** Fetches research data from SongStats (sole provider). */ export async function fetchResearchProvider( path: string, queryParams?: Record, ): Promise { - if (getResearchProvider() === "chartmetric") { - return fetchChartmetric(path, queryParams); - } - return fetchSongstatsResearch(path, queryParams); } diff --git a/lib/research/providers/getResearchProvider.ts b/lib/research/providers/getResearchProvider.ts deleted file mode 100644 index 8a93416fc..000000000 --- a/lib/research/providers/getResearchProvider.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ResearchProvider } from "@/lib/research/providers/ResearchProvider"; - -export function getResearchProvider(): ResearchProvider { - const provider = process.env.RESEARCH_PROVIDER?.trim().toLowerCase(); - if (provider === "chartmetric") return "chartmetric"; - return "songstats"; -} diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index 154cc5a56..c8e2ab1d7 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -3,7 +3,6 @@ import getSearch from "@/lib/spotify/getSearch"; import { handleResearch } from "@/lib/research/handleResearch"; interface GetIdsResponse { - chartmetric_ids?: number[]; songstats_track_ids?: string[]; songstats_track_id?: string; id?: string | number; @@ -12,10 +11,7 @@ interface GetIdsResponse { function extractProviderTrackId(data: unknown): string | undefined { const ids = (Array.isArray(data) ? data[0] : data) as GetIdsResponse | undefined; const id = - ids?.songstats_track_ids?.[0] ?? - ids?.songstats_track_id ?? - ids?.chartmetric_ids?.[0] ?? - ids?.id; + ids?.songstats_track_ids?.[0] ?? ids?.songstats_track_id ?? ids?.id; return id === undefined || id === null || id === "" ? undefined : String(id); } From 45ee3d5e744e6bd3765aad213440703dc52cb167 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 4 Jun 2026 19:08:24 -0700 Subject: [PATCH 13/21] refactor: remove research API routes and handlers This commit deletes various research-related API routes and their corresponding handler functions, including charts, cities, curator, discover, festivals, genres, Instagram posts, playlists, radio, and venues. The removal streamlines the codebase by eliminating unused functionality and focuses on maintaining only essential components. No new errors introduced; existing tests remain intact. --- app/api/research/charts/route.ts | 22 --- app/api/research/cities/route.ts | 22 --- app/api/research/curator/route.ts | 22 --- app/api/research/discover/route.ts | 22 --- app/api/research/festivals/route.ts | 22 --- app/api/research/genres/route.ts | 22 --- app/api/research/instagram-posts/route.ts | 22 --- app/api/research/playlist/route.ts | 22 --- app/api/research/radio/route.ts | 22 --- app/api/research/venues/route.ts | 22 --- .../getResearchChartsHandler.test.ts | 87 ----------- .../getResearchDiscoverHandler.test.ts | 142 ------------------ .../getResearchPlaylistHandler.test.ts | 89 ----------- lib/research/__tests__/handleResearch.test.ts | 12 +- .../validateGetResearchChartsRequest.test.ts | 118 --------------- .../validateGetResearchCuratorRequest.test.ts | 100 ------------ ...validateGetResearchDiscoverRequest.test.ts | 79 ---------- ...alidateGetResearchFestivalsRequest.test.ts | 41 ----- .../validateGetResearchGenresRequest.test.ts | 41 ----- ...validateGetResearchPlaylistRequest.test.ts | 75 --------- .../validateGetResearchRadioRequest.test.ts | 42 ------ lib/research/getResearchChartsHandler.ts | 43 ------ lib/research/getResearchCitiesHandler.ts | 44 ------ lib/research/getResearchCuratorHandler.ts | 38 ----- lib/research/getResearchDiscoverHandler.ts | 48 ------ lib/research/getResearchFestivalsHandler.ts | 31 ---- lib/research/getResearchGenresHandler.ts | 30 ---- .../getResearchInstagramPostsHandler.ts | 39 ----- lib/research/getResearchPlaylistHandler.ts | 36 ----- lib/research/getResearchRadioHandler.ts | 30 ---- lib/research/getResearchVenuesHandler.ts | 31 ---- .../validateGetResearchChartsRequest.ts | 65 -------- .../validateGetResearchCuratorRequest.ts | 46 ------ .../validateGetResearchDiscoverRequest.ts | 58 ------- .../validateGetResearchFestivalsRequest.ts | 24 --- .../validateGetResearchGenresRequest.ts | 24 --- .../validateGetResearchPlaylistRequest.ts | 43 ------ .../validateGetResearchRadioRequest.ts | 24 --- 38 files changed, 8 insertions(+), 1692 deletions(-) delete mode 100644 app/api/research/charts/route.ts delete mode 100644 app/api/research/cities/route.ts delete mode 100644 app/api/research/curator/route.ts delete mode 100644 app/api/research/discover/route.ts delete mode 100644 app/api/research/festivals/route.ts delete mode 100644 app/api/research/genres/route.ts delete mode 100644 app/api/research/instagram-posts/route.ts delete mode 100644 app/api/research/playlist/route.ts delete mode 100644 app/api/research/radio/route.ts delete mode 100644 app/api/research/venues/route.ts delete mode 100644 lib/research/__tests__/getResearchChartsHandler.test.ts delete mode 100644 lib/research/__tests__/getResearchDiscoverHandler.test.ts delete mode 100644 lib/research/__tests__/getResearchPlaylistHandler.test.ts delete mode 100644 lib/research/__tests__/validateGetResearchChartsRequest.test.ts delete mode 100644 lib/research/__tests__/validateGetResearchCuratorRequest.test.ts delete mode 100644 lib/research/__tests__/validateGetResearchDiscoverRequest.test.ts delete mode 100644 lib/research/__tests__/validateGetResearchFestivalsRequest.test.ts delete mode 100644 lib/research/__tests__/validateGetResearchGenresRequest.test.ts delete mode 100644 lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts delete mode 100644 lib/research/__tests__/validateGetResearchRadioRequest.test.ts delete mode 100644 lib/research/getResearchChartsHandler.ts delete mode 100644 lib/research/getResearchCitiesHandler.ts delete mode 100644 lib/research/getResearchCuratorHandler.ts delete mode 100644 lib/research/getResearchDiscoverHandler.ts delete mode 100644 lib/research/getResearchFestivalsHandler.ts delete mode 100644 lib/research/getResearchGenresHandler.ts delete mode 100644 lib/research/getResearchInstagramPostsHandler.ts delete mode 100644 lib/research/getResearchPlaylistHandler.ts delete mode 100644 lib/research/getResearchRadioHandler.ts delete mode 100644 lib/research/getResearchVenuesHandler.ts delete mode 100644 lib/research/validateGetResearchChartsRequest.ts delete mode 100644 lib/research/validateGetResearchCuratorRequest.ts delete mode 100644 lib/research/validateGetResearchDiscoverRequest.ts delete mode 100644 lib/research/validateGetResearchFestivalsRequest.ts delete mode 100644 lib/research/validateGetResearchGenresRequest.ts delete mode 100644 lib/research/validateGetResearchPlaylistRequest.ts delete mode 100644 lib/research/validateGetResearchRadioRequest.ts diff --git a/app/api/research/charts/route.ts b/app/api/research/charts/route.ts deleted file mode 100644 index c92c5b2f5..000000000 --- a/app/api/research/charts/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchChartsHandler } from "@/lib/research/getResearchChartsHandler"; - -/** - * OPTIONS /api/research/charts — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/charts — Global chart positions by platform and country. Requires `?artist=` query param. - * - * @param request - must include `artist` query param - * @returns JSON chart positions or error - */ -export async function GET(request: NextRequest) { - return getResearchChartsHandler(request); -} diff --git a/app/api/research/cities/route.ts b/app/api/research/cities/route.ts deleted file mode 100644 index 28920d381..000000000 --- a/app/api/research/cities/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchCitiesHandler } from "@/lib/research/getResearchCitiesHandler"; - -/** - * OPTIONS /api/research/cities — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/cities — Geographic listening data for an artist. Requires `?artist=` query param. - * - * @param request - must include `artist` query param - * @returns JSON city-level listener data or error - */ -export async function GET(request: NextRequest) { - return getResearchCitiesHandler(request); -} diff --git a/app/api/research/curator/route.ts b/app/api/research/curator/route.ts deleted file mode 100644 index 6f3c7668d..000000000 --- a/app/api/research/curator/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchCuratorHandler } from "@/lib/research/getResearchCuratorHandler"; - -/** - * OPTIONS /api/research/curator — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/curator — Playlist curator details. Requires `?platform=` and `?id=` query params. - * - * @param request - must include `platform` and `id` query params - * @returns JSON curator profile or error - */ -export async function GET(request: NextRequest) { - return getResearchCuratorHandler(request); -} diff --git a/app/api/research/discover/route.ts b/app/api/research/discover/route.ts deleted file mode 100644 index 836a0a8a9..000000000 --- a/app/api/research/discover/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchDiscoverHandler } from "@/lib/research/getResearchDiscoverHandler"; - -/** - * OPTIONS /api/research/discover — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/discover — Discover artists by genre, country, and growth criteria. Supports `?genre=`, `?country=`, `?sort=`, `?limit=` filters. - * - * @param request - filter criteria via query params - * @returns JSON array of matching artists or error - */ -export async function GET(request: NextRequest) { - return getResearchDiscoverHandler(request); -} diff --git a/app/api/research/festivals/route.ts b/app/api/research/festivals/route.ts deleted file mode 100644 index 6cd15d3c7..000000000 --- a/app/api/research/festivals/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchFestivalsHandler } from "@/lib/research/getResearchFestivalsHandler"; - -/** - * OPTIONS /api/research/festivals — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/festivals — List of music festivals. - * - * @param request - optional filter query params - * @returns JSON festival list or error - */ -export async function GET(request: NextRequest) { - return getResearchFestivalsHandler(request); -} diff --git a/app/api/research/genres/route.ts b/app/api/research/genres/route.ts deleted file mode 100644 index c8a665ba1..000000000 --- a/app/api/research/genres/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchGenresHandler } from "@/lib/research/getResearchGenresHandler"; - -/** - * OPTIONS /api/research/genres — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/genres — All available genre IDs and names. - * - * @param request - no required query params - * @returns JSON genre list or error - */ -export async function GET(request: NextRequest) { - return getResearchGenresHandler(request); -} diff --git a/app/api/research/instagram-posts/route.ts b/app/api/research/instagram-posts/route.ts deleted file mode 100644 index 4330e24d1..000000000 --- a/app/api/research/instagram-posts/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchInstagramPostsHandler } from "@/lib/research/getResearchInstagramPostsHandler"; - -/** - * OPTIONS /api/research/instagram-posts — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/instagram-posts — Recent Instagram posts for an artist. Requires `?artist=` query param. - * - * @param request - must include `artist` query param - * @returns JSON Instagram posts or error - */ -export async function GET(request: NextRequest) { - return getResearchInstagramPostsHandler(request); -} diff --git a/app/api/research/playlist/route.ts b/app/api/research/playlist/route.ts deleted file mode 100644 index db8e8c067..000000000 --- a/app/api/research/playlist/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchPlaylistHandler } from "@/lib/research/getResearchPlaylistHandler"; - -/** - * OPTIONS /api/research/playlist — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/playlist — Details for a specific playlist. Requires `?platform=` and `?id=` query params. - * - * @param request - must include `platform` and `id` query params - * @returns JSON playlist details or error - */ -export async function GET(request: NextRequest) { - return getResearchPlaylistHandler(request); -} diff --git a/app/api/research/radio/route.ts b/app/api/research/radio/route.ts deleted file mode 100644 index 351c52a27..000000000 --- a/app/api/research/radio/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchRadioHandler } from "@/lib/research/getResearchRadioHandler"; - -/** - * OPTIONS /api/research/radio — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/radio — List of radio stations. - * - * @param request - optional filter query params - * @returns JSON radio station list or error - */ -export async function GET(request: NextRequest) { - return getResearchRadioHandler(request); -} diff --git a/app/api/research/venues/route.ts b/app/api/research/venues/route.ts deleted file mode 100644 index 2db8bfb31..000000000 --- a/app/api/research/venues/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchVenuesHandler } from "@/lib/research/getResearchVenuesHandler"; - -/** - * OPTIONS /api/research/venues — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/venues — Venues an artist has performed at. Requires `?artist=` query param. - * - * @param request - must include `artist` query param - * @returns JSON venue list or error - */ -export async function GET(request: NextRequest) { - return getResearchVenuesHandler(request); -} diff --git a/lib/research/__tests__/getResearchChartsHandler.test.ts b/lib/research/__tests__/getResearchChartsHandler.test.ts deleted file mode 100644 index 4cefd3840..000000000 --- a/lib/research/__tests__/getResearchChartsHandler.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -import { getResearchChartsHandler } from "../getResearchChartsHandler"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ - fetchResearchProvider: vi.fn(), -})); - -vi.mock("@/lib/credits/recordCreditDeduction", () => ({ - recordCreditDeduction: vi.fn(), -})); - -describe("getResearchChartsHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "token", - }); - }); - - it("returns 400 when platform is missing", async () => { - const req = new NextRequest("http://localhost/api/research/charts"); - const res = await getResearchChartsHandler(req); - expect(res.status).toBe(400); - }); - - it("returns 400 when platform contains path traversal", async () => { - const req = new NextRequest("http://localhost/api/research/charts?platform=../admin"); - const res = await getResearchChartsHandler(req); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toContain("Invalid platform"); - }); - - it("returns 400 when platform contains slashes", async () => { - const req = new NextRequest("http://localhost/api/research/charts?platform=foo/bar"); - const res = await getResearchChartsHandler(req); - expect(res.status).toBe(400); - }); - - it("defaults type to 'regional' and interval to 'daily'", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ - data: { chart: [] }, - status: 200, - }); - - const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&country=US"); - await getResearchChartsHandler(req); - - const calledParams = vi.mocked(fetchResearchProvider).mock.calls[0][1]; - expect(calledParams).toHaveProperty("type", "regional"); - expect(calledParams).toHaveProperty("interval", "daily"); - expect(calledParams).toHaveProperty("country_code", "US"); - }); - - it("preserves user-provided type and interval", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ - data: { chart: [] }, - status: 200, - }); - - const req = new NextRequest( - "http://localhost/api/research/charts?platform=spotify&type=viral&interval=weekly&country=US", - ); - await getResearchChartsHandler(req); - - const calledParams = vi.mocked(fetchResearchProvider).mock.calls[0][1]; - expect(calledParams).toMatchObject({ type: "viral", interval: "weekly" }); - }); -}); diff --git a/lib/research/__tests__/getResearchDiscoverHandler.test.ts b/lib/research/__tests__/getResearchDiscoverHandler.test.ts deleted file mode 100644 index dc9d1a564..000000000 --- a/lib/research/__tests__/getResearchDiscoverHandler.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { getResearchDiscoverHandler } from "../getResearchDiscoverHandler"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ - fetchResearchProvider: vi.fn(), -})); - -vi.mock("@/lib/credits/recordCreditDeduction", () => ({ - recordCreditDeduction: vi.fn(), -})); - -describe("getResearchDiscoverHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 401 when auth fails", async () => { - const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errorResponse); - - const req = new NextRequest("http://localhost/api/research/discover?country=US"); - const res = await getResearchDiscoverHandler(req); - expect(res.status).toBe(401); - }); - - it("returns 400 when country is not 2 letters", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "tok", - } as ReturnType extends Promise - ? Exclude - : never); - - const req = new NextRequest("http://localhost/api/research/discover?country=USA"); - const res = await getResearchDiscoverHandler(req); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.status).toBe("error"); - expect(body.error).toContain("2-letter"); - }); - - it("returns 400 when limit is negative", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "tok", - } as ReturnType extends Promise - ? Exclude - : never); - - const req = new NextRequest("http://localhost/api/research/discover?limit=-5"); - const res = await getResearchDiscoverHandler(req); - expect(res.status).toBe(400); - }); - - it("returns artists on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "tok", - } as ReturnType extends Promise - ? Exclude - : never); - - vi.mocked(fetchResearchProvider).mockResolvedValue({ - data: [ - { name: "Artist A", sp_monthly_listeners: 100000 }, - { name: "Artist B", sp_monthly_listeners: 200000 }, - ], - status: 200, - }); - - const req = new NextRequest("http://localhost/api/research/discover?country=US&limit=10"); - const res = await getResearchDiscoverHandler(req); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.status).toBe("success"); - expect(body.artists).toHaveLength(2); - expect(body.artists[0].name).toBe("Artist A"); - }); - - it("passes sp_ml range when both min and max provided", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "tok", - } as ReturnType extends Promise - ? Exclude - : never); - - vi.mocked(fetchResearchProvider).mockResolvedValue({ - data: [], - status: 200, - }); - - const req = new NextRequest( - "http://localhost/api/research/discover?sp_monthly_listeners_min=50000&sp_monthly_listeners_max=200000", - ); - await getResearchDiscoverHandler(req); - - expect(fetchResearchProvider).toHaveBeenCalledWith( - "/artist/list/filter", - expect.objectContaining({ "sp_ml[]": "50000,200000" }), - ); - }); - - it("returns empty array when proxy fails", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "test-id", - orgId: null, - authToken: "tok", - } as ReturnType extends Promise - ? Exclude - : never); - - vi.mocked(fetchResearchProvider).mockResolvedValue({ - data: null, - status: 500, - }); - - const req = new NextRequest("http://localhost/api/research/discover?country=US"); - const res = await getResearchDiscoverHandler(req); - expect(res.status).toBe(500); - }); -}); diff --git a/lib/research/__tests__/getResearchPlaylistHandler.test.ts b/lib/research/__tests__/getResearchPlaylistHandler.test.ts deleted file mode 100644 index cd23aff81..000000000 --- a/lib/research/__tests__/getResearchPlaylistHandler.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { getResearchPlaylistHandler } from "../getResearchPlaylistHandler"; -import { validateGetResearchPlaylistRequest } from "../validateGetResearchPlaylistRequest"; -import { handleResearch } from "../handleResearch"; - -vi.mock("@/lib/research/ensureResearchCredits", () => ({ - ensureResearchCredits: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("../validateGetResearchPlaylistRequest", () => ({ - validateGetResearchPlaylistRequest: vi.fn(), -})); - -vi.mock("../handleResearch", () => ({ - handleResearch: vi.fn(), -})); - -describe("getResearchPlaylistHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(validateGetResearchPlaylistRequest).mockResolvedValue({ - accountId: "test-id", - platform: "spotify", - id: "37i9dQZF1DXcBWIGoYBM5M", - }); - }); - - it("passes through validator error response", async () => { - const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateGetResearchPlaylistRequest).mockResolvedValue(err); - const req = new NextRequest("http://localhost/api/research/playlist"); - const res = await getResearchPlaylistHandler(req); - expect(res).toBe(err); - }); - - it("fetches /playlist/:platform/:id and returns 200 with the data", async () => { - vi.mocked(handleResearch).mockResolvedValueOnce({ - data: { id: "37i9dQZF1DXcBWIGoYBM5M", name: "RapCaviar" }, - }); - const req = new NextRequest( - "http://localhost/api/research/playlist?platform=spotify&id=37i9dQZF1DXcBWIGoYBM5M", - ); - const res = await getResearchPlaylistHandler(req); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.status).toBe("success"); - expect(body.name).toBe("RapCaviar"); - expect(handleResearch).toHaveBeenCalledTimes(1); - expect(handleResearch).toHaveBeenCalledWith({ - accountId: "test-id", - path: "/playlist/spotify/37i9dQZF1DXcBWIGoYBM5M", - }); - }); - - it("propagates upstream error status with a 'Playlist lookup failed' message", async () => { - vi.mocked(handleResearch).mockResolvedValueOnce({ - error: "Request failed with status 404", - status: 404, - }); - const req = new NextRequest( - "http://localhost/api/research/playlist?platform=spotify&id=unknown", - ); - const res = await getResearchPlaylistHandler(req); - expect(res.status).toBe(404); - const body = await res.json(); - expect(body.error).toBe("Playlist lookup failed"); - }); - - it("does NOT perform a fallback search when id is non-numeric", async () => { - vi.mocked(handleResearch).mockResolvedValueOnce({ - data: { id: "abc", name: "Something" }, - }); - const req = new NextRequest("http://localhost/api/research/playlist?platform=spotify&id=abc"); - await getResearchPlaylistHandler(req); - - expect(handleResearch).toHaveBeenCalledTimes(1); - expect(handleResearch).toHaveBeenCalledWith({ - accountId: "test-id", - path: "/playlist/spotify/37i9dQZF1DXcBWIGoYBM5M", - }); - }); -}); diff --git a/lib/research/__tests__/handleResearch.test.ts b/lib/research/__tests__/handleResearch.test.ts index 40a34978e..e41851107 100644 --- a/lib/research/__tests__/handleResearch.test.ts +++ b/lib/research/__tests__/handleResearch.test.ts @@ -29,11 +29,14 @@ describe("handleResearch", () => { const result = await handleResearch({ accountId: "acc_1", - path: "/charts/spotify", - query: { country_code: "US" }, + path: "/search", + query: { q: "Drake", type: "artists" }, }); - expect(fetchResearchProvider).toHaveBeenCalledWith("/charts/spotify", { country_code: "US" }); + expect(fetchResearchProvider).toHaveBeenCalledWith("/search", { + q: "Drake", + type: "artists", + }); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 5, @@ -47,7 +50,8 @@ describe("handleResearch", () => { const result = await handleResearch({ accountId: "acc_1", - path: "/charts/spotify", + path: "/search", + query: { q: "Drake" }, }); expect(result).toEqual({ error: "Request failed with status 502", status: 502 }); diff --git a/lib/research/__tests__/validateGetResearchChartsRequest.test.ts b/lib/research/__tests__/validateGetResearchChartsRequest.test.ts deleted file mode 100644 index 0bd2ee719..000000000 --- a/lib/research/__tests__/validateGetResearchChartsRequest.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { validateGetResearchChartsRequest } from "../validateGetResearchChartsRequest"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -const okAuth = { - accountId: "acc_1", - orgId: null, - authToken: "tok", -} as ReturnType extends Promise - ? Exclude - : never; - -describe("validateGetResearchChartsRequest", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns the auth response when auth fails", async () => { - const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(authErr); - - const req = new NextRequest("http://localhost/api/research/charts?platform=spotify"); - const res = await validateGetResearchChartsRequest(req); - expect(res).toBe(authErr); - }); - - it("returns 400 when platform is missing", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/charts"); - const res = await validateGetResearchChartsRequest(req); - expect(res).toBeInstanceOf(NextResponse); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toBe("platform parameter is required"); - }); - - it("returns 400 when platform is not lowercase alpha", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/charts?platform=Spotify/x"); - const res = await validateGetResearchChartsRequest(req); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toContain("Invalid platform"); - }); - - it("fills in defaults when optional params are omitted", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/charts?platform=spotify"); - const res = await validateGetResearchChartsRequest(req); - expect(res).toEqual({ - accountId: "acc_1", - platform: "spotify", - country: "US", - interval: "daily", - type: "regional", - latest: "true", - }); - }); - - it("preserves explicit params over defaults", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest( - "http://localhost/api/research/charts?platform=spotify&country=GB&interval=weekly&type=viral&latest=false", - ); - const res = await validateGetResearchChartsRequest(req); - expect(res).toMatchObject({ - country: "GB", - interval: "weekly", - type: "viral", - latest: "false", - }); - }); - - it("returns 400 for an unknown type (not regional or viral)", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&type=top"); - const res = await validateGetResearchChartsRequest(req); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toContain("type must be one of"); - expect(body.error).toContain("regional"); - expect(body.error).toContain("viral"); - }); - - it("returns 400 for an unknown interval (not daily or weekly)", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest( - "http://localhost/api/research/charts?platform=spotify&interval=monthly", - ); - const res = await validateGetResearchChartsRequest(req); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toContain("interval must be one of"); - }); - - it("returns 400 for a latest value that isn't a boolean string", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/charts?platform=spotify&latest=yes"); - const res = await validateGetResearchChartsRequest(req); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toContain("latest must be"); - }); -}); diff --git a/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts b/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts deleted file mode 100644 index eb9467d61..000000000 --- a/lib/research/__tests__/validateGetResearchCuratorRequest.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { validateGetResearchCuratorRequest } from "../validateGetResearchCuratorRequest"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -describe("validateGetResearchCuratorRequest", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc-1", - orgId: null, - authToken: "t", - }); - }); - - it("returns auth error when auth fails", async () => { - const errResp = NextResponse.json({ error: "no" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errResp); - const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?platform=spotify&id=2"), - ); - expect(result).toBe(errResp); - }); - - it("returns 400 when platform is missing", async () => { - const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?id=2"), - ); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.error).toBe("platform parameter is required"); - } - }); - - it("returns 400 when id is missing", async () => { - const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?platform=spotify"), - ); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.error).toBe("id parameter is required"); - } - }); - - it("returns 400 when platform is not spotify/applemusic/deezer", async () => { - const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?platform=youtube&id=2"), - ); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.error).toBe("Invalid platform. Must be one of: spotify, applemusic, deezer"); - } - }); - - it("returns 400 when id is non-numeric", async () => { - const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?platform=spotify&id=spotify"), - ); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = await result.json(); - expect(body.error).toBe("id must be a numeric curator ID (e.g. 2 for Spotify)"); - } - }); - - it("returns accountId, platform, id on success", async () => { - const result = await validateGetResearchCuratorRequest( - new NextRequest("http://localhost/api/research/curator?platform=spotify&id=2"), - ); - expect(result).toEqual({ accountId: "acc-1", platform: "spotify", id: "2" }); - }); - - it("accepts applemusic and deezer as valid platforms", async () => { - for (const platform of ["applemusic", "deezer"] as const) { - const result = await validateGetResearchCuratorRequest( - new NextRequest(`http://localhost/api/research/curator?platform=${platform}&id=1000`), - ); - expect(result).toEqual({ accountId: "acc-1", platform, id: "1000" }); - } - }); -}); diff --git a/lib/research/__tests__/validateGetResearchDiscoverRequest.test.ts b/lib/research/__tests__/validateGetResearchDiscoverRequest.test.ts deleted file mode 100644 index 46d7bfd5b..000000000 --- a/lib/research/__tests__/validateGetResearchDiscoverRequest.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { validateGetResearchDiscoverRequest } from "../validateGetResearchDiscoverRequest"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -const okAuth = { - accountId: "acc_1", - orgId: null, - authToken: "tok", -} as ReturnType extends Promise - ? Exclude - : never; - -describe("validateGetResearchDiscoverRequest", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns the auth response when auth fails", async () => { - const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(authErr); - - const req = new NextRequest("http://localhost/api/research/discover"); - const res = await validateGetResearchDiscoverRequest(req); - expect(res).toBe(authErr); - }); - - it("returns 400 when country is not 2 letters", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/discover?country=USA"); - const res = await validateGetResearchDiscoverRequest(req); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toContain("2-letter"); - }); - - it("returns 400 when limit exceeds max", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/discover?limit=500"); - const res = await validateGetResearchDiscoverRequest(req); - expect((res as NextResponse).status).toBe(400); - }); - - it("returns parsed values plus accountId on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest( - "http://localhost/api/research/discover?country=US&genre=rock&limit=10&sp_monthly_listeners_min=100&sp_monthly_listeners_max=500", - ); - const res = await validateGetResearchDiscoverRequest(req); - expect(res).toEqual({ - accountId: "acc_1", - country: "US", - genre: "rock", - limit: 10, - sp_monthly_listeners_min: 100, - sp_monthly_listeners_max: 500, - }); - }); - - it("returns just accountId when no params are provided", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/discover"); - const res = await validateGetResearchDiscoverRequest(req); - expect(res).toEqual({ accountId: "acc_1" }); - }); -}); diff --git a/lib/research/__tests__/validateGetResearchFestivalsRequest.test.ts b/lib/research/__tests__/validateGetResearchFestivalsRequest.test.ts deleted file mode 100644 index 59165d233..000000000 --- a/lib/research/__tests__/validateGetResearchFestivalsRequest.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { validateGetResearchFestivalsRequest } from "../validateGetResearchFestivalsRequest"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -describe("validateGetResearchFestivalsRequest", () => { - beforeEach(() => vi.clearAllMocks()); - - it("returns auth error when auth fails", async () => { - const errResp = NextResponse.json({ error: "no" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errResp); - const result = await validateGetResearchFestivalsRequest( - new NextRequest("http://localhost/api/research/festivals"), - ); - expect(result).toBe(errResp); - }); - - it("returns accountId on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc-1", - orgId: null, - authToken: "t", - }); - const result = await validateGetResearchFestivalsRequest( - new NextRequest("http://localhost/api/research/festivals"), - ); - expect(result).toEqual({ accountId: "acc-1" }); - }); -}); diff --git a/lib/research/__tests__/validateGetResearchGenresRequest.test.ts b/lib/research/__tests__/validateGetResearchGenresRequest.test.ts deleted file mode 100644 index b18f4e1bd..000000000 --- a/lib/research/__tests__/validateGetResearchGenresRequest.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { validateGetResearchGenresRequest } from "../validateGetResearchGenresRequest"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -describe("validateGetResearchGenresRequest", () => { - beforeEach(() => vi.clearAllMocks()); - - it("returns auth error when auth fails", async () => { - const errResp = NextResponse.json({ error: "no" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errResp); - const result = await validateGetResearchGenresRequest( - new NextRequest("http://localhost/api/research/genres"), - ); - expect(result).toBe(errResp); - }); - - it("returns accountId on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc-1", - orgId: null, - authToken: "t", - }); - const result = await validateGetResearchGenresRequest( - new NextRequest("http://localhost/api/research/genres"), - ); - expect(result).toEqual({ accountId: "acc-1" }); - }); -}); diff --git a/lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts b/lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts deleted file mode 100644 index 9bcb56878..000000000 --- a/lib/research/__tests__/validateGetResearchPlaylistRequest.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { validateGetResearchPlaylistRequest } from "../validateGetResearchPlaylistRequest"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -const okAuth = { - accountId: "acc_1", - orgId: null, - authToken: "tok", -} as ReturnType extends Promise - ? Exclude - : never; - -describe("validateGetResearchPlaylistRequest", () => { - beforeEach(() => vi.clearAllMocks()); - - it("returns the auth response when auth fails", async () => { - const authErr = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(authErr); - const req = new NextRequest("http://localhost/api/research/playlist?platform=spotify&id=1"); - const res = await validateGetResearchPlaylistRequest(req); - expect(res).toBe(authErr); - }); - - it("returns 400 when platform is missing", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/playlist?id=1"); - const res = await validateGetResearchPlaylistRequest(req); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toContain("platform and id"); - }); - - it("returns 400 when id is missing", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/playlist?platform=spotify"); - const res = await validateGetResearchPlaylistRequest(req); - expect((res as NextResponse).status).toBe(400); - }); - - it("returns 400 for invalid platform", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest("http://localhost/api/research/playlist?platform=bogus&id=1"); - const res = await validateGetResearchPlaylistRequest(req); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toContain("Invalid platform"); - }); - - it("returns validated request on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const req = new NextRequest( - "http://localhost/api/research/playlist?platform=spotify&id=37i9dQZF1DXcBWIGoYBM5M", - ); - const res = await validateGetResearchPlaylistRequest(req); - expect(res).toEqual({ - accountId: "acc_1", - platform: "spotify", - id: "37i9dQZF1DXcBWIGoYBM5M", - }); - }); -}); diff --git a/lib/research/__tests__/validateGetResearchRadioRequest.test.ts b/lib/research/__tests__/validateGetResearchRadioRequest.test.ts deleted file mode 100644 index 5dc105e53..000000000 --- a/lib/research/__tests__/validateGetResearchRadioRequest.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; - -import { validateGetResearchRadioRequest } from "../validateGetResearchRadioRequest"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -describe("validateGetResearchRadioRequest", () => { - beforeEach(() => vi.clearAllMocks()); - - it("returns auth error when auth fails", async () => { - const errResp = NextResponse.json({ error: "no" }, { status: 401 }); - vi.mocked(validateAuthContext).mockResolvedValue(errResp); - - const result = await validateGetResearchRadioRequest( - new NextRequest("http://localhost/api/research/radio"), - ); - expect(result).toBe(errResp); - }); - - it("returns accountId on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "acc-1", - orgId: null, - authToken: "t", - }); - const result = await validateGetResearchRadioRequest( - new NextRequest("http://localhost/api/research/radio"), - ); - expect(result).toEqual({ accountId: "acc-1" }); - }); -}); diff --git a/lib/research/getResearchChartsHandler.ts b/lib/research/getResearchChartsHandler.ts deleted file mode 100644 index 2ff7abaaa..000000000 --- a/lib/research/getResearchChartsHandler.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { errorResponse } from "@/lib/networking/errorResponse"; -import { successResponse } from "@/lib/networking/successResponse"; -import { handleResearch } from "@/lib/research/handleResearch"; -import { validateGetResearchChartsRequest } from "@/lib/research/validateGetResearchChartsRequest"; - -/** - * GET /api/research/charts - * - * Returns global chart positions for a platform. Not artist-scoped. - * Requires `platform` query param. Optional: `country`, `interval`, `type`, `latest`. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchChartsHandler(request: NextRequest): Promise { - try { - const validated = await validateGetResearchChartsRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleResearch({ - accountId: validated.accountId, - path: `/charts/${validated.platform}`, - query: { - country_code: validated.country, - interval: validated.interval, - type: validated.type, - latest: validated.latest, - }, - }); - - if ("error" in result) return errorResponse(result.error, result.status); - - const data = result.data; - const body = - typeof data === "object" && data !== null && !Array.isArray(data) - ? (data as Record) - : { data }; - return successResponse(body); - } catch (error) { - console.error("[ERROR] getResearchChartsHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchCitiesHandler.ts b/lib/research/getResearchCitiesHandler.ts deleted file mode 100644 index 57c2926ee..000000000 --- a/lib/research/getResearchCitiesHandler.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { successResponse } from "@/lib/networking/successResponse"; -import { errorResponse } from "@/lib/networking/errorResponse"; - -/** - * GET /api/research/cities - * - * Returns geographic listening data showing where people listen to the artist. - * Requires `artist` query param. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchCitiesHandler(request: NextRequest): Promise { - try { - const validated = await validateArtistRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - ...validated, - path: cmId => `/artist/${cmId}/where-people-listen`, - }); - - if ("error" in result) return errorResponse(result.error, 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 successResponse({ cities }); - } catch (error) { - console.error("[ERROR] getResearchCitiesHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchCuratorHandler.ts b/lib/research/getResearchCuratorHandler.ts deleted file mode 100644 index a5acfee4d..000000000 --- a/lib/research/getResearchCuratorHandler.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { errorResponse } from "@/lib/networking/errorResponse"; -import { successResponse } from "@/lib/networking/successResponse"; -import { handleResearch } from "@/lib/research/handleResearch"; -import { validateGetResearchCuratorRequest } from "@/lib/research/validateGetResearchCuratorRequest"; - -/** - * GET /api/research/curator - * - * Returns details for a specific playlist curator when the configured research - * provider supports legacy numeric curator identifiers. Not artist-scoped — - * keyed by `platform` and `id` query params, proxied to `/curator/{platform}/{id}`. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchCuratorHandler(request: NextRequest): Promise { - try { - const validated = await validateGetResearchCuratorRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleResearch({ - accountId: validated.accountId, - path: `/curator/${validated.platform}/${validated.id}`, - }); - - if ("error" in result) return errorResponse(result.error, result.status); - - const data = result.data; - const body = - typeof data === "object" && data !== null && !Array.isArray(data) - ? (data as Record) - : { data }; - return successResponse(body); - } catch (error) { - console.error("[ERROR] getResearchCuratorHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchDiscoverHandler.ts b/lib/research/getResearchDiscoverHandler.ts deleted file mode 100644 index 7e822ea02..000000000 --- a/lib/research/getResearchDiscoverHandler.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { errorResponse } from "@/lib/networking/errorResponse"; -import { successResponse } from "@/lib/networking/successResponse"; -import { handleResearch } from "@/lib/research/handleResearch"; -import { validateGetResearchDiscoverRequest } from "@/lib/research/validateGetResearchDiscoverRequest"; - -/** - * GET /api/research/discover - * - * Filters artists by country, genre, listener ranges, and growth rate. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchDiscoverHandler(request: NextRequest): Promise { - try { - const validated = await validateGetResearchDiscoverRequest(request); - if (validated instanceof NextResponse) return validated; - - const query: Record = {}; - if (validated.country) query.code2 = validated.country; - if (validated.genre) query.tagId = validated.genre; - if (validated.sort) query.sortColumn = validated.sort; - if (validated.limit !== undefined) query.limit = String(validated.limit); - if ( - validated.sp_monthly_listeners_min !== undefined && - validated.sp_monthly_listeners_max !== undefined - ) { - query["sp_ml[]"] = - `${validated.sp_monthly_listeners_min},${validated.sp_monthly_listeners_max}`; - } else if (validated.sp_monthly_listeners_min !== undefined) { - query["sp_ml[]"] = String(validated.sp_monthly_listeners_min); - } else if (validated.sp_monthly_listeners_max !== undefined) { - query["sp_ml[]"] = String(validated.sp_monthly_listeners_max); - } - const result = await handleResearch({ - accountId: validated.accountId, - path: "/artist/list/filter", - query, - }); - - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ artists: Array.isArray(result.data) ? result.data : [] }); - } catch (error) { - console.error("[ERROR] getResearchDiscoverHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchFestivalsHandler.ts b/lib/research/getResearchFestivalsHandler.ts deleted file mode 100644 index 87f405604..000000000 --- a/lib/research/getResearchFestivalsHandler.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { errorResponse } from "@/lib/networking/errorResponse"; -import { successResponse } from "@/lib/networking/successResponse"; -import { handleResearch } from "@/lib/research/handleResearch"; -import { validateGetResearchFestivalsRequest } from "@/lib/research/validateGetResearchFestivalsRequest"; - -/** - * GET /api/research/festivals - * - * Returns a list of music festivals. Not artist-scoped — `/festival/list` is a - * global provider endpoint, so this uses `handleResearch`. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchFestivalsHandler(request: NextRequest): Promise { - try { - const validated = await validateGetResearchFestivalsRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleResearch({ - accountId: validated.accountId, - path: "/festival/list", - }); - - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ festivals: Array.isArray(result.data) ? result.data : [] }); - } catch (error) { - console.error("[ERROR] getResearchFestivalsHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchGenresHandler.ts b/lib/research/getResearchGenresHandler.ts deleted file mode 100644 index e6f730e01..000000000 --- a/lib/research/getResearchGenresHandler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { errorResponse } from "@/lib/networking/errorResponse"; -import { successResponse } from "@/lib/networking/successResponse"; -import { handleResearch } from "@/lib/research/handleResearch"; -import { validateGetResearchGenresRequest } from "@/lib/research/validateGetResearchGenresRequest"; - -/** - * GET /api/research/genres - * - * Returns all available genre IDs and names. Not artist-scoped. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchGenresHandler(request: NextRequest): Promise { - try { - const validated = await validateGetResearchGenresRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleResearch({ - accountId: validated.accountId, - path: "/genres", - }); - - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ genres: Array.isArray(result.data) ? result.data : [] }); - } catch (error) { - console.error("[ERROR] getResearchGenresHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchInstagramPostsHandler.ts b/lib/research/getResearchInstagramPostsHandler.ts deleted file mode 100644 index 68ce31cd5..000000000 --- a/lib/research/getResearchInstagramPostsHandler.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { successResponse } from "@/lib/networking/successResponse"; -import { errorResponse } from "@/lib/networking/errorResponse"; - -/** - * GET /api/research/instagram-posts - * - * Returns recent Instagram posts for the given artist when SongStats supports it. - * Requires `artist` query param. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchInstagramPostsHandler( - request: NextRequest, -): Promise { - try { - const validated = await validateArtistRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - ...validated, - path: cmId => `/SNS/deepSocial/cm_artist/${cmId}/instagram`, - }); - - if ("error" in result) return errorResponse(result.error, result.status); - const data = result.data; - const body = - typeof data === "object" && data !== null && !Array.isArray(data) - ? (data as Record) - : { data }; - return successResponse(body); - } catch (error) { - console.error("[ERROR] getResearchInstagramPostsHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchPlaylistHandler.ts b/lib/research/getResearchPlaylistHandler.ts deleted file mode 100644 index 3b3140162..000000000 --- a/lib/research/getResearchPlaylistHandler.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { errorResponse } from "@/lib/networking/errorResponse"; -import { successResponse } from "@/lib/networking/successResponse"; -import { handleResearch } from "@/lib/research/handleResearch"; -import { validateGetResearchPlaylistRequest } from "@/lib/research/validateGetResearchPlaylistRequest"; - -/** - * GET /api/research/playlist - * - * Returns provider playlist details for the supplied `platform` and `id`. - * - * @param request - query params: platform, id - * @returns JSON playlist details or error - */ -export async function getResearchPlaylistHandler(request: NextRequest): Promise { - try { - const validated = await validateGetResearchPlaylistRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleResearch({ - accountId: validated.accountId, - path: `/playlist/${validated.platform}/${validated.id}`, - }); - - if ("error" in result) return errorResponse("Playlist lookup failed", result.status); - - const data = result.data; - const body = - typeof data === "object" && data !== null && !Array.isArray(data) - ? (data as Record) - : { data }; - return successResponse(body); - } catch (error) { - console.error("[ERROR] getResearchPlaylistHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchRadioHandler.ts b/lib/research/getResearchRadioHandler.ts deleted file mode 100644 index fd1097185..000000000 --- a/lib/research/getResearchRadioHandler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { errorResponse } from "@/lib/networking/errorResponse"; -import { successResponse } from "@/lib/networking/successResponse"; -import { handleResearch } from "@/lib/research/handleResearch"; -import { validateGetResearchRadioRequest } from "@/lib/research/validateGetResearchRadioRequest"; - -/** - * GET /api/research/radio - * - * Returns a list of radio stations. Not artist-scoped. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchRadioHandler(request: NextRequest): Promise { - try { - const validated = await validateGetResearchRadioRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleResearch({ - accountId: validated.accountId, - path: "/radio/station-list", - }); - - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ stations: Array.isArray(result.data) ? result.data : [] }); - } catch (error) { - console.error("[ERROR] getResearchRadioHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/getResearchVenuesHandler.ts b/lib/research/getResearchVenuesHandler.ts deleted file mode 100644 index 4f19663d5..000000000 --- a/lib/research/getResearchVenuesHandler.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { successResponse } from "@/lib/networking/successResponse"; -import { errorResponse } from "@/lib/networking/errorResponse"; - -/** - * GET /api/research/venues - * - * Returns venues the artist has performed at, including capacity and location. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchVenuesHandler(request: NextRequest): Promise { - try { - const validated = await validateArtistRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - ...validated, - path: cmId => `/artist/${cmId}/venues`, - }); - - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ venues: Array.isArray(result.data) ? result.data : [] }); - } catch (error) { - console.error("[ERROR] getResearchVenuesHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/validateGetResearchChartsRequest.ts b/lib/research/validateGetResearchChartsRequest.ts deleted file mode 100644 index 070bbe936..000000000 --- a/lib/research/validateGetResearchChartsRequest.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; -import { errorResponse } from "@/lib/networking/errorResponse"; - -const VALID_TYPES = ["regional", "viral"] as const; -const VALID_INTERVALS = ["daily", "weekly"] as const; -const VALID_LATEST = ["true", "false"] as const; - -export type ValidatedGetResearchChartsRequest = { - accountId: string; - platform: string; - country: string; - interval: string; - type: string; - latest: string; -}; - -/** - * Validates `GET /api/research/charts` — auth + `platform` (required, lowercase - * alpha) + defaults for `country` ("US"), `interval` ("daily"), `type` - * ("regional"), and `latest` ("true"). `interval`, `type`, and `latest` are - * rejected at this layer if they aren't in the documented enum — this turns - * an opaque upstream provider 400 into a specific 400 from us. - * - * @param request - The incoming HTTP request. - */ -export async function validateGetResearchChartsRequest( - request: NextRequest, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform"); - if (!platform) return errorResponse("platform parameter is required", 400); - if (!/^[a-z]+$/.test(platform)) return errorResponse("Invalid platform parameter", 400); - - const type = searchParams.get("type") || "regional"; - if (!(VALID_TYPES as readonly string[]).includes(type)) { - return errorResponse(`type must be one of: ${VALID_TYPES.join(", ")}`, 400); - } - - const interval = searchParams.get("interval") || "daily"; - if (!(VALID_INTERVALS as readonly string[]).includes(interval)) { - return errorResponse(`interval must be one of: ${VALID_INTERVALS.join(", ")}`, 400); - } - - const latest = searchParams.get("latest") ?? "true"; - if (!(VALID_LATEST as readonly string[]).includes(latest)) { - return errorResponse(`latest must be "true" or "false"`, 400); - } - - const short = await ensureResearchCredits(authResult.accountId); - if (short) return short; - - return { - accountId: authResult.accountId, - platform, - country: searchParams.get("country") || "US", - interval, - type, - latest, - }; -} diff --git a/lib/research/validateGetResearchCuratorRequest.ts b/lib/research/validateGetResearchCuratorRequest.ts deleted file mode 100644 index 1693b9e97..000000000 --- a/lib/research/validateGetResearchCuratorRequest.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; -import { errorResponse } from "@/lib/networking/errorResponse"; - -const VALID_PLATFORMS = ["spotify", "applemusic", "deezer"] as const; -type Platform = (typeof VALID_PLATFORMS)[number]; - -export type ValidatedGetResearchCuratorRequest = { - accountId: string; - platform: Platform; - id: string; -}; - -/** - * Validates `GET /api/research/curator` — auth + required `platform` (enum) - * and `id` (legacy provider numeric curator ID). - * - * @param request - The incoming HTTP request. - */ -export async function validateGetResearchCuratorRequest( - request: NextRequest, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform"); - const id = searchParams.get("id"); - - if (!platform) return errorResponse("platform parameter is required", 400); - if (!id) return errorResponse("id parameter is required", 400); - - if (!VALID_PLATFORMS.includes(platform as Platform)) { - return errorResponse(`Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, 400); - } - - if (!/^\d+$/.test(id)) { - return errorResponse("id must be a numeric curator ID (e.g. 2 for Spotify)", 400); - } - - const short = await ensureResearchCredits(authResult.accountId); - if (short) return short; - - return { accountId: authResult.accountId, platform: platform as Platform, id }; -} diff --git a/lib/research/validateGetResearchDiscoverRequest.ts b/lib/research/validateGetResearchDiscoverRequest.ts deleted file mode 100644 index e1592ed6c..000000000 --- a/lib/research/validateGetResearchDiscoverRequest.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; -import { z } from "zod"; - -export const discoverQuerySchema = z.object({ - country: z.string().length(2, "country must be a 2-letter ISO code").optional(), - genre: z.string().optional(), - sort: z.string().optional(), - limit: z.coerce.number().int().min(1).max(100).optional(), - sp_monthly_listeners_min: z.coerce.number().int().min(0).optional(), - sp_monthly_listeners_max: z.coerce.number().int().min(0).optional(), -}); - -export type DiscoverQuery = z.infer; - -export type ValidatedGetResearchDiscoverRequest = DiscoverQuery & { accountId: string }; - -/** - * Validates `GET /api/research/discover` — auth + filter query params. - * - * @param request - The incoming HTTP request. - */ -export async function validateGetResearchDiscoverRequest( - request: NextRequest, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - const { searchParams } = new URL(request.url); - const raw: Record = {}; - for (const key of [ - "country", - "genre", - "sort", - "limit", - "sp_monthly_listeners_min", - "sp_monthly_listeners_max", - ]) { - const val = searchParams.get(key); - if (val) raw[key] = val; - } - - const result = discoverQuerySchema.safeParse(raw); - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { status: "error", error: firstError.message }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const short = await ensureResearchCredits(authResult.accountId); - if (short) return short; - - return { accountId: authResult.accountId, ...result.data }; -} diff --git a/lib/research/validateGetResearchFestivalsRequest.ts b/lib/research/validateGetResearchFestivalsRequest.ts deleted file mode 100644 index 1cac79802..000000000 --- a/lib/research/validateGetResearchFestivalsRequest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; - -export type ValidatedGetResearchFestivalsRequest = { - accountId: string; -}; - -/** - * Validates `GET /api/research/festivals` — auth only. No required query params. - * - * @param request - The incoming HTTP request. - */ -export async function validateGetResearchFestivalsRequest( - request: NextRequest, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - const short = await ensureResearchCredits(authResult.accountId); - if (short) return short; - - return { accountId: authResult.accountId }; -} diff --git a/lib/research/validateGetResearchGenresRequest.ts b/lib/research/validateGetResearchGenresRequest.ts deleted file mode 100644 index 864f6afb5..000000000 --- a/lib/research/validateGetResearchGenresRequest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; - -export type ValidatedGetResearchGenresRequest = { - accountId: string; -}; - -/** - * Validates `GET /api/research/genres` — auth only. No required query params. - * - * @param request - The incoming HTTP request. - */ -export async function validateGetResearchGenresRequest( - request: NextRequest, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - const short = await ensureResearchCredits(authResult.accountId); - if (short) return short; - - return { accountId: authResult.accountId }; -} diff --git a/lib/research/validateGetResearchPlaylistRequest.ts b/lib/research/validateGetResearchPlaylistRequest.ts deleted file mode 100644 index 5c1686da1..000000000 --- a/lib/research/validateGetResearchPlaylistRequest.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; -import { errorResponse } from "@/lib/networking/errorResponse"; - -const VALID_PLATFORMS = ["spotify", "applemusic", "deezer", "amazon", "youtube"]; - -export type ValidatedGetResearchPlaylistRequest = { - accountId: string; - platform: string; - id: string; -}; - -/** - * Validates `GET /api/research/playlist` — auth + required `platform` (one of - * spotify/applemusic/deezer/amazon/youtube) and `id` (the platform-native - * playlist ID; format varies by platform, e.g. Spotify base62, Apple/Deezer - * numeric). Discovery by name is the caller's job via `GET /api/research`. - * - * @param request - The incoming HTTP request. - */ -export async function validateGetResearchPlaylistRequest( - request: NextRequest, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - const { searchParams } = new URL(request.url); - const platform = searchParams.get("platform"); - const id = searchParams.get("id"); - - if (!platform || !id) { - return errorResponse("platform and id parameters are required", 400); - } - if (!VALID_PLATFORMS.includes(platform)) { - return errorResponse(`Invalid platform. Must be one of: ${VALID_PLATFORMS.join(", ")}`, 400); - } - - const short = await ensureResearchCredits(authResult.accountId); - if (short) return short; - - return { accountId: authResult.accountId, platform, id }; -} diff --git a/lib/research/validateGetResearchRadioRequest.ts b/lib/research/validateGetResearchRadioRequest.ts deleted file mode 100644 index ccebf651b..000000000 --- a/lib/research/validateGetResearchRadioRequest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; - -export type ValidatedGetResearchRadioRequest = { - accountId: string; -}; - -/** - * Validates `GET /api/research/radio` — auth only. No required query params. - * - * @param request - The incoming HTTP request. - */ -export async function validateGetResearchRadioRequest( - request: NextRequest, -): Promise { - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) return authResult; - - const short = await ensureResearchCredits(authResult.accountId); - if (short) return short; - - return { accountId: authResult.accountId }; -} From 20f3856ed447cc4e619693db1c9d1ebcea9a02cf Mon Sep 17 00:00:00 2001 From: john Date: Thu, 4 Jun 2026 19:12:30 -0700 Subject: [PATCH 14/21] refactor: simplify research search handler and validation This commit updates the research search handler and its validation logic to remove support for the deprecated `beta` and `platforms` parameters. The search functionality now exclusively utilizes the `offset` parameter for pagination. Corresponding test cases have been adjusted to reflect these changes, ensuring clarity and consistency in the API's behavior. No new errors introduced; existing tests remain intact. --- .../__tests__/getResearchSearchHandler.test.ts | 14 ++++---------- .../validateGetResearchSearchRequest.test.ts | 8 +++----- lib/research/getResearchSearchHandler.ts | 5 ++--- .../__tests__/fetchSongstatsResearch.test.ts | 1 - lib/research/validateGetResearchSearchRequest.ts | 8 +------- 5 files changed, 10 insertions(+), 26 deletions(-) diff --git a/lib/research/__tests__/getResearchSearchHandler.test.ts b/lib/research/__tests__/getResearchSearchHandler.test.ts index ab0e1302a..9b71bbaac 100644 --- a/lib/research/__tests__/getResearchSearchHandler.test.ts +++ b/lib/research/__tests__/getResearchSearchHandler.test.ts @@ -29,8 +29,6 @@ describe("getResearchSearchHandler", () => { q: "Drake", type: "artists", limit: "10", - beta: undefined, - platforms: undefined, offset: undefined, }); }); @@ -79,19 +77,17 @@ describe("getResearchSearchHandler", () => { }); }); - it("forwards beta, platforms, and offset to the provider when provided", async () => { + it("forwards offset to the provider when provided", async () => { vi.mocked(validateGetResearchSearchRequest).mockResolvedValue({ accountId: "test-id", q: "Hotline Bling", type: "tracks", limit: "25", - beta: "true", - platforms: "cm,spotify", offset: "5", }); vi.mocked(handleResearch).mockResolvedValue({ data: { tracks: [] } }); const req = new NextRequest( - "http://localhost/api/research/search?q=Hotline+Bling&type=tracks&beta=true&platforms=cm,spotify&offset=5&limit=25", + "http://localhost/api/research/search?q=Hotline+Bling&type=tracks&offset=5&limit=25", ); await getResearchSearchHandler(req); @@ -102,18 +98,16 @@ describe("getResearchSearchHandler", () => { q: "Hotline Bling", type: "tracks", limit: "25", - beta: "true", - platforms: "cm,spotify", offset: "5", }, }); }); - it("returns suggestions when the beta engine returns a suggestions array", async () => { + it("returns suggestions when the provider returns a suggestions array", async () => { vi.mocked(handleResearch).mockResolvedValue({ data: { suggestions: [{ name: "Drake", target: "artists", match_strength: 0.99 }] }, }); - const req = new NextRequest("http://localhost/api/research/search?q=Drake&beta=true"); + const req = new NextRequest("http://localhost/api/research/search?q=Drake"); const res = await getResearchSearchHandler(req); const body = await res.json(); expect(res.status).toBe(200); diff --git a/lib/research/__tests__/validateGetResearchSearchRequest.test.ts b/lib/research/__tests__/validateGetResearchSearchRequest.test.ts index 3890b0f40..e9a344cfb 100644 --- a/lib/research/__tests__/validateGetResearchSearchRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchSearchRequest.test.ts @@ -53,8 +53,6 @@ describe("validateGetResearchSearchRequest", () => { q: "Drake", type: "artists", limit: "10", - beta: undefined, - platforms: undefined, offset: undefined, }); }); @@ -68,12 +66,12 @@ describe("validateGetResearchSearchRequest", () => { expect(res).toMatchObject({ type: "tracks", limit: "25" }); }); - it("passes through beta, platforms, and offset when provided", async () => { + it("passes through offset when provided", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest( - "http://localhost/api/research/search?q=Drake&beta=true&platforms=cm,spotify&offset=5", + "http://localhost/api/research/search?q=Drake&type=tracks&limit=25&offset=5", ); const res = await validateGetResearchSearchRequest(req); - expect(res).toMatchObject({ beta: "true", platforms: "cm,spotify", offset: "5" }); + expect(res).toMatchObject({ offset: "5" }); }); }); diff --git a/lib/research/getResearchSearchHandler.ts b/lib/research/getResearchSearchHandler.ts index 1afbe2e95..59721852f 100644 --- a/lib/research/getResearchSearchHandler.ts +++ b/lib/research/getResearchSearchHandler.ts @@ -7,7 +7,7 @@ import { validateGetResearchSearchRequest } from "@/lib/research/validateGetRese /** * GET /api/research/search * - * Searches the configured research provider for artists, tracks, or labels by name. + * Searches SongStats for artists, tracks, or labels by name. * * @param request - must include `q` query param * @returns JSON search results or error @@ -22,9 +22,8 @@ export async function getResearchSearchHandler(request: NextRequest): Promise { q: "Drake", type: "artists", limit: "1", - beta: "true", }); expect(fetchSongstats).toHaveBeenCalledWith("/artists/search", { diff --git a/lib/research/validateGetResearchSearchRequest.ts b/lib/research/validateGetResearchSearchRequest.ts index e9b3cb57c..10768c25b 100644 --- a/lib/research/validateGetResearchSearchRequest.ts +++ b/lib/research/validateGetResearchSearchRequest.ts @@ -8,16 +8,12 @@ export type ValidatedGetResearchSearchRequest = { q: string; type: string; limit: string; - beta: string | undefined; - platforms: string | undefined; offset: string | undefined; }; /** * Validates `GET /api/research/search` — auth + required `q` query param, with - * defaults for `type` ("artists") and `limit` ("10"). Also accepts the optional - * legacy provider pass-throughs: `beta`, `platforms` (comma-separated string), - * and `offset` (numeric string for paging). + * defaults for `type` ("artists") and `limit` ("10"). Optional `offset` for paging. * * @param request - The incoming HTTP request. */ @@ -39,8 +35,6 @@ export async function validateGetResearchSearchRequest( q, type: searchParams.get("type") || "artists", limit: searchParams.get("limit") || "10", - beta: searchParams.get("beta") ?? undefined, - platforms: searchParams.get("platforms") ?? undefined, offset: searchParams.get("offset") ?? undefined, }; } From 3d7ba8183344948b7dfb736f48c2820269e8647b Mon Sep 17 00:00:00 2001 From: john Date: Thu, 4 Jun 2026 19:17:07 -0700 Subject: [PATCH 15/21] refactor: streamline track ID extraction logic This commit simplifies the `extractProviderTrackId` function by ensuring consistent handling of track ID retrieval from the response data. The logic remains intact, but the formatting has been adjusted for clarity. No new errors introduced; existing tests remain intact. --- lib/research/resolveTrack.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index c8e2ab1d7..02b297f5b 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -10,8 +10,7 @@ interface GetIdsResponse { function extractProviderTrackId(data: unknown): string | undefined { const ids = (Array.isArray(data) ? data[0] : data) as GetIdsResponse | undefined; - const id = - ids?.songstats_track_ids?.[0] ?? ids?.songstats_track_id ?? ids?.id; + const id = ids?.songstats_track_ids?.[0] ?? ids?.songstats_track_id ?? ids?.id; return id === undefined || id === null || id === "" ? undefined : String(id); } From 4fb8760bdb00bfa9a96b7c74cf2bc473b46d3f43 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 4 Jun 2026 19:31:30 -0700 Subject: [PATCH 16/21] refactor: replace fetchResearchProvider with fetchSongstatsResearch This commit updates the research-related files to exclusively use the `fetchSongstatsResearch` function instead of the deprecated `fetchResearchProvider`. The changes include modifications to the `getResearchMetrics`, `handleArtistResearch`, `handleResearch`, and `resolveArtist` functions, ensuring that all research data retrieval is streamlined through the SongStats API. Additionally, a new `ProxyResult` type is introduced to standardize the response structure. Corresponding tests have been updated to reflect these changes, maintaining existing functionality and ensuring no new errors are introduced. --- lib/research/{providers => }/ProxyResult.ts | 0 .../getResearchSimilarHandler.test.ts | 18 +++++++------- .../__tests__/handleArtistResearch.test.ts | 24 +++++++++---------- lib/research/__tests__/handleResearch.test.ts | 16 ++++++------- lib/research/__tests__/resolveArtist.test.ts | 18 +++++++------- .../validateGetResearchLookupRequest.test.ts | 12 ++++++++++ .../validateGetResearchMetricsRequest.test.ts | 7 ++++++ lib/research/getResearchMetrics.ts | 4 ++-- lib/research/handleArtistResearch.ts | 4 ++-- lib/research/handleResearch.ts | 4 ++-- .../__tests__/fetchResearchProvider.test.ts | 23 ------------------ .../providers/fetchResearchProvider.ts | 10 -------- lib/research/resolveArtist.ts | 4 ++-- .../songstats/fetchSongstatsResearch.ts | 2 +- .../songstats/mapSongstatsArtistPath.ts | 2 +- .../songstats/mapSongstatsTrackPath.ts | 2 +- .../songstats/songstatsResearchMapping.ts | 2 +- .../validateGetResearchLookupRequest.ts | 3 ++- .../__tests__/fetchSongstats.test.ts | 9 +++---- lib/songstats/fetchSongstats.ts | 7 ++++-- 20 files changed, 81 insertions(+), 90 deletions(-) rename lib/research/{providers => }/ProxyResult.ts (100%) delete mode 100644 lib/research/providers/__tests__/fetchResearchProvider.test.ts delete mode 100644 lib/research/providers/fetchResearchProvider.ts diff --git a/lib/research/providers/ProxyResult.ts b/lib/research/ProxyResult.ts similarity index 100% rename from lib/research/providers/ProxyResult.ts rename to lib/research/ProxyResult.ts diff --git a/lib/research/__tests__/getResearchSimilarHandler.test.ts b/lib/research/__tests__/getResearchSimilarHandler.test.ts index ecf12c6b8..2bc1ab90a 100644 --- a/lib/research/__tests__/getResearchSimilarHandler.test.ts +++ b/lib/research/__tests__/getResearchSimilarHandler.test.ts @@ -4,7 +4,7 @@ import { NextRequest } from "next/server"; import { getResearchSimilarHandler } from "../getResearchSimilarHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), @@ -22,8 +22,8 @@ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ - fetchResearchProvider: vi.fn(), +vi.mock("@/lib/research/songstats/fetchSongstatsResearch", () => ({ + fetchSongstatsResearch: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ @@ -42,7 +42,7 @@ describe("getResearchSimilarHandler", () => { }); it("uses by-configurations path with default params when no config params provided", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ data: [{ id: 100, name: "Kendrick Lamar" }], status: 200, }); @@ -52,13 +52,13 @@ describe("getResearchSimilarHandler", () => { expect(res.status).toBe(200); // Should call by-configurations, NOT relatedartists - const calledPath = vi.mocked(fetchResearchProvider).mock.calls[0][0]; + const calledPath = vi.mocked(fetchSongstatsResearch).mock.calls[0][0]; expect(calledPath).toContain("by-configurations"); expect(calledPath).not.toContain("relatedartists"); }); it("uses by-configurations path when config params are provided", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ data: [{ id: 100, name: "Kendrick Lamar" }], status: 200, }); @@ -67,12 +67,12 @@ describe("getResearchSimilarHandler", () => { const res = await getResearchSimilarHandler(req); expect(res.status).toBe(200); - const calledPath = vi.mocked(fetchResearchProvider).mock.calls[0][0]; + const calledPath = vi.mocked(fetchSongstatsResearch).mock.calls[0][0]; expect(calledPath).toContain("by-configurations"); }); it("passes default medium values for config params when none provided", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ data: [], status: 200, }); @@ -80,7 +80,7 @@ describe("getResearchSimilarHandler", () => { const req = new NextRequest("http://localhost/api/research/similar?artist=Drake"); await getResearchSimilarHandler(req); - const calledParams = vi.mocked(fetchResearchProvider).mock.calls[0][1]; + const calledParams = vi.mocked(fetchSongstatsResearch).mock.calls[0][1]; expect(calledParams).toMatchObject({ audience: "medium", genre: "medium", diff --git a/lib/research/__tests__/handleArtistResearch.test.ts b/lib/research/__tests__/handleArtistResearch.test.ts index cc0b6289c..a725e56c9 100644 --- a/lib/research/__tests__/handleArtistResearch.test.ts +++ b/lib/research/__tests__/handleArtistResearch.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleArtistResearch } from "../handleArtistResearch"; import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ @@ -12,8 +12,8 @@ vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ vi.mock("@/lib/research/resolveArtist", () => ({ resolveArtist: vi.fn(), })); -vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ - fetchResearchProvider: vi.fn(), +vi.mock("@/lib/research/songstats/fetchSongstatsResearch", () => ({ + fetchSongstatsResearch: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: vi.fn(), @@ -34,13 +34,13 @@ describe("handleArtistResearch", () => { }); expect(result).toEqual({ error: "Artist not found", status: 404 }); - expect(fetchResearchProvider).not.toHaveBeenCalled(); + expect(fetchSongstatsResearch).not.toHaveBeenCalled(); expect(recordCreditDeduction).not.toHaveBeenCalled(); }); it("proxies to the built path and returns { data } on success", async () => { vi.mocked(resolveArtist).mockResolvedValue({ id: "42" } as never); - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: [{ name: "a" }], } as never); @@ -52,7 +52,7 @@ describe("handleArtistResearch", () => { path: id => `/artist/${id}/albums`, }); - expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/42/albums", undefined); + expect(fetchSongstatsResearch).toHaveBeenCalledWith("/artist/42/albums", undefined); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", creditsToDeduct: 5, @@ -63,7 +63,7 @@ describe("handleArtistResearch", () => { it("forwards query params to the provider fetcher", async () => { vi.mocked(resolveArtist).mockResolvedValue({ id: "7" } as never); - vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: {} } as never); + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: {} } as never); await handleArtistResearch({ artist: "X", @@ -72,7 +72,7 @@ describe("handleArtistResearch", () => { query: { limit: "10", platform: "spotify" }, }); - expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/7/playlists", { + expect(fetchSongstatsResearch).toHaveBeenCalledWith("/artist/7/playlists", { limit: "10", platform: "spotify", }); @@ -80,7 +80,7 @@ describe("handleArtistResearch", () => { it("returns the upstream status as an error when proxy is non-200", async () => { vi.mocked(resolveArtist).mockResolvedValue({ id: "1" } as never); - vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 502, data: null } as never); + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 502, data: null } as never); const result = await handleArtistResearch({ artist: "X", @@ -94,7 +94,7 @@ describe("handleArtistResearch", () => { it("swallows credit-deduction failures and still returns data", async () => { vi.mocked(resolveArtist).mockResolvedValue({ id: "1" } as never); - vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: "ok" } as never); vi.mocked(recordCreditDeduction).mockRejectedValue(new Error("DB down")); const result = await handleArtistResearch({ @@ -107,7 +107,7 @@ describe("handleArtistResearch", () => { }); it("uses an explicit provider artist id without resolving by name", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: "ok" } as never); vi.mocked(recordCreditDeduction).mockResolvedValue(undefined as never); const result = await handleArtistResearch({ @@ -118,7 +118,7 @@ describe("handleArtistResearch", () => { }); expect(resolveArtist).not.toHaveBeenCalled(); - expect(fetchResearchProvider).toHaveBeenCalledWith("/artist/artist_123", undefined); + expect(fetchSongstatsResearch).toHaveBeenCalledWith("/artist/artist_123", undefined); expect(result).toEqual({ data: "ok" }); }); }); diff --git a/lib/research/__tests__/handleResearch.test.ts b/lib/research/__tests__/handleResearch.test.ts index e41851107..cd03bba49 100644 --- a/lib/research/__tests__/handleResearch.test.ts +++ b/lib/research/__tests__/handleResearch.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { handleResearch } from "../handleResearch"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), })); -vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ - fetchResearchProvider: vi.fn(), +vi.mock("@/lib/research/songstats/fetchSongstatsResearch", () => ({ + fetchSongstatsResearch: vi.fn(), })); vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: vi.fn(), @@ -21,7 +21,7 @@ describe("handleResearch", () => { }); it("returns { data } on 200 and deducts the default 5 credits", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: [{ id: 1 }], } as never); @@ -33,7 +33,7 @@ describe("handleResearch", () => { query: { q: "Drake", type: "artists" }, }); - expect(fetchResearchProvider).toHaveBeenCalledWith("/search", { + expect(fetchSongstatsResearch).toHaveBeenCalledWith("/search", { q: "Drake", type: "artists", }); @@ -46,7 +46,7 @@ describe("handleResearch", () => { }); it("returns { error, status } when proxy is non-200 and skips deduction", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 502, data: null } as never); + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 502, data: null } as never); const result = await handleResearch({ accountId: "acc_1", @@ -59,7 +59,7 @@ describe("handleResearch", () => { }); it("still returns { data } when credit deduction throws", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: "ok" } as never); + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: "ok" } as never); vi.mocked(recordCreditDeduction).mockRejectedValue(new Error("DB down")); const result = await handleResearch({ @@ -71,7 +71,7 @@ describe("handleResearch", () => { }); it("respects the credits override", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ status: 200, data: {} } as never); + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: {} } as never); vi.mocked(recordCreditDeduction).mockResolvedValue(undefined as never); await handleResearch({ diff --git a/lib/research/__tests__/resolveArtist.test.ts b/lib/research/__tests__/resolveArtist.test.ts index 2c737ba7e..b7ba726e4 100644 --- a/lib/research/__tests__/resolveArtist.test.ts +++ b/lib/research/__tests__/resolveArtist.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { resolveArtist } from "../resolveArtist"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; -vi.mock("@/lib/research/providers/fetchResearchProvider", () => ({ - fetchResearchProvider: vi.fn(), +vi.mock("@/lib/research/songstats/fetchSongstatsResearch", () => ({ + fetchSongstatsResearch: vi.fn(), })); describe("resolveArtist", () => { @@ -16,7 +16,7 @@ describe("resolveArtist", () => { const result = await resolveArtist("3380"); expect(result).toEqual({ id: "3380" }); - expect(fetchResearchProvider).not.toHaveBeenCalled(); + expect(fetchSongstatsResearch).not.toHaveBeenCalled(); }); it("returns error for UUID (not yet implemented)", async () => { @@ -27,7 +27,7 @@ describe("resolveArtist", () => { }); it("searches the configured provider by name and returns top match", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ data: { artists: [{ id: 3380, name: "Drake" }] }, status: 200, }); @@ -35,7 +35,7 @@ describe("resolveArtist", () => { const result = await resolveArtist("Drake"); expect(result).toEqual({ id: "3380" }); - expect(fetchResearchProvider).toHaveBeenCalledWith("/search", { + expect(fetchSongstatsResearch).toHaveBeenCalledWith("/search", { q: "Drake", type: "artists", limit: "1", @@ -43,7 +43,7 @@ describe("resolveArtist", () => { }); it("returns error when no artist found", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ data: { artists: [] }, status: 200, }); @@ -55,7 +55,7 @@ describe("resolveArtist", () => { }); it("returns error when search fails", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ data: { error: "failed" }, status: 500, }); @@ -80,7 +80,7 @@ describe("resolveArtist", () => { }); it("returns SongStats string IDs from provider-backed search results", async () => { - vi.mocked(fetchResearchProvider).mockResolvedValue({ + vi.mocked(fetchSongstatsResearch).mockResolvedValue({ data: { artists: [{ id: "artist_123", name: "Test Artist" }] }, status: 200, }); diff --git a/lib/research/__tests__/validateGetResearchLookupRequest.test.ts b/lib/research/__tests__/validateGetResearchLookupRequest.test.ts index 5dda4be2f..def7390aa 100644 --- a/lib/research/__tests__/validateGetResearchLookupRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchLookupRequest.test.ts @@ -78,6 +78,18 @@ describe("validateGetResearchLookupRequest", () => { expect(body.error).toContain("Spotify artist URL"); }); + it("returns 400 when url only contains a Spotify artist path as a substring", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + const req = new NextRequest( + "http://localhost/api/research/lookup?url=https://evil.com/redirect?to=https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", + ); + const res = await validateGetResearchLookupRequest(req); + + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toContain("Spotify artist URL"); + }); + it("returns 400 when Spotify URL contains a malformed artist ID", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); const req = new NextRequest( diff --git a/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts b/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts index ecb3e9f96..87154f0ce 100644 --- a/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts +++ b/lib/research/__tests__/validateGetResearchMetricsRequest.test.ts @@ -67,4 +67,11 @@ describe("validateGetResearchMetricsRequest", () => { ); expect(result).toEqual({ accountId: "acc_1", artist: "Drake", source: "radio" }); }); + + it("accepts SongStats sxm metric sources", async () => { + const result = await validateGetResearchMetricsRequest( + new NextRequest("http://x/?artist=Drake&source=sxm"), + ); + expect(result).toEqual({ accountId: "acc_1", artist: "Drake", source: "sxm" }); + }); }); diff --git a/lib/research/getResearchMetrics.ts b/lib/research/getResearchMetrics.ts index 5ee2c14a8..5fa2c2aa0 100644 --- a/lib/research/getResearchMetrics.ts +++ b/lib/research/getResearchMetrics.ts @@ -1,4 +1,4 @@ -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import { resolveArtist } from "@/lib/research/resolveArtist"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; @@ -25,7 +25,7 @@ export async function getResearchMetrics( if ("error" in resolved) return { error: resolved.error, status: 404 }; const path = `/artist/${resolved.id}/stat/${params.source}`; - const result = await fetchResearchProvider(path, undefined); + const result = await fetchSongstatsResearch(path, undefined); if (result.status !== 200) { return { error: `Request failed with status ${result.status}`, status: result.status }; } diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts index b815100a1..565c0c267 100644 --- a/lib/research/handleArtistResearch.ts +++ b/lib/research/handleArtistResearch.ts @@ -1,5 +1,5 @@ import { resolveArtist } from "@/lib/research/resolveArtist"; -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; export type HandleArtistResearchParams = { @@ -31,7 +31,7 @@ export async function handleArtistResearch( const resolved = artistId ? { id: artistId } : await resolveArtist(artist); if ("error" in resolved) return { error: resolved.error, status: 404 }; - const result = await fetchResearchProvider(path(resolved.id), query); + const result = await fetchSongstatsResearch(path(resolved.id), query); if (result.status !== 200) { return { error: `Request failed with status ${result.status}`, status: result.status }; } diff --git a/lib/research/handleResearch.ts b/lib/research/handleResearch.ts index 7cad3b2f0..0c35d9730 100644 --- a/lib/research/handleResearch.ts +++ b/lib/research/handleResearch.ts @@ -1,4 +1,4 @@ -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; export type HandleResearchParams = { @@ -25,7 +25,7 @@ export type HandleResearchResult = { data: unknown } | { error: string; status: export async function handleResearch(params: HandleResearchParams): Promise { const { accountId, path, query, credits = 5 } = params; - const result = await fetchResearchProvider(path, query); + const result = await fetchSongstatsResearch(path, query); if (result.status !== 200) { return { error: `Request failed with status ${result.status}`, status: result.status }; } diff --git a/lib/research/providers/__tests__/fetchResearchProvider.test.ts b/lib/research/providers/__tests__/fetchResearchProvider.test.ts deleted file mode 100644 index 330c7ed7b..000000000 --- a/lib/research/providers/__tests__/fetchResearchProvider.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { fetchResearchProvider } from "../fetchResearchProvider"; -import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; - -vi.mock("@/lib/research/songstats/fetchSongstatsResearch", () => ({ - fetchSongstatsResearch: vi.fn(), -})); - -describe("fetchResearchProvider", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("delegates to SongStats research", async () => { - vi.mocked(fetchSongstatsResearch).mockResolvedValue({ status: 200, data: { ok: true } }); - - const result = await fetchResearchProvider("/search", { q: "Drake" }); - - expect(result).toEqual({ status: 200, data: { ok: true } }); - expect(fetchSongstatsResearch).toHaveBeenCalledWith("/search", { q: "Drake" }); - }); -}); diff --git a/lib/research/providers/fetchResearchProvider.ts b/lib/research/providers/fetchResearchProvider.ts deleted file mode 100644 index 868250a4c..000000000 --- a/lib/research/providers/fetchResearchProvider.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; -import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; - -/** Fetches research data from SongStats (sole provider). */ -export async function fetchResearchProvider( - path: string, - queryParams?: Record, -): Promise { - return fetchSongstatsResearch(path, queryParams); -} diff --git a/lib/research/resolveArtist.ts b/lib/research/resolveArtist.ts index b0f2fa675..7df89f12a 100644 --- a/lib/research/resolveArtist.ts +++ b/lib/research/resolveArtist.ts @@ -1,4 +1,4 @@ -import { fetchResearchProvider } from "@/lib/research/providers/fetchResearchProvider"; +import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -33,7 +33,7 @@ export async function resolveArtist( }; } - const result = await fetchResearchProvider("/search", { + const result = await fetchSongstatsResearch("/search", { q: trimmed, type: "artists", limit: "1", diff --git a/lib/research/songstats/fetchSongstatsResearch.ts b/lib/research/songstats/fetchSongstatsResearch.ts index b385423ec..8c27408ff 100644 --- a/lib/research/songstats/fetchSongstatsResearch.ts +++ b/lib/research/songstats/fetchSongstatsResearch.ts @@ -1,4 +1,4 @@ -import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import type { ProxyResult } from "@/lib/research/ProxyResult"; import { mapSongstatsArtistPath } from "@/lib/research/songstats/mapSongstatsArtistPath"; import { mapSongstatsTrackPath } from "@/lib/research/songstats/mapSongstatsTrackPath"; import { diff --git a/lib/research/songstats/mapSongstatsArtistPath.ts b/lib/research/songstats/mapSongstatsArtistPath.ts index 97a0f7ffa..86a7886d5 100644 --- a/lib/research/songstats/mapSongstatsArtistPath.ts +++ b/lib/research/songstats/mapSongstatsArtistPath.ts @@ -1,4 +1,4 @@ -import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import type { ProxyResult } from "@/lib/research/ProxyResult"; import { extractList, mapSongstatsResult, diff --git a/lib/research/songstats/mapSongstatsTrackPath.ts b/lib/research/songstats/mapSongstatsTrackPath.ts index c1204397f..9a7ae6c0b 100644 --- a/lib/research/songstats/mapSongstatsTrackPath.ts +++ b/lib/research/songstats/mapSongstatsTrackPath.ts @@ -1,4 +1,4 @@ -import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import type { ProxyResult } from "@/lib/research/ProxyResult"; import { extractList, mapSongstatsResult, diff --git a/lib/research/songstats/songstatsResearchMapping.ts b/lib/research/songstats/songstatsResearchMapping.ts index bea1ea470..83d205d3b 100644 --- a/lib/research/songstats/songstatsResearchMapping.ts +++ b/lib/research/songstats/songstatsResearchMapping.ts @@ -1,4 +1,4 @@ -import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import type { ProxyResult } from "@/lib/research/ProxyResult"; import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; export type JsonRecord = Record; diff --git a/lib/research/validateGetResearchLookupRequest.ts b/lib/research/validateGetResearchLookupRequest.ts index c47461786..ebbdc4781 100644 --- a/lib/research/validateGetResearchLookupRequest.ts +++ b/lib/research/validateGetResearchLookupRequest.ts @@ -4,7 +4,8 @@ import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; import { errorResponse } from "@/lib/networking/errorResponse"; const SPOTIFY_ID_REGEX = /^[A-Za-z0-9]{22}$/; -const SPOTIFY_ARTIST_REGEX = /spotify\.com\/artist\/([A-Za-z0-9]{22})(?:[/?#]|$)/; +const SPOTIFY_ARTIST_REGEX = + /^https?:\/\/(?:open\.)?spotify\.com\/artist\/([A-Za-z0-9]{22})(?:[/?#]|$)/i; export type ValidatedGetResearchLookupRequest = { accountId: string; diff --git a/lib/songstats/__tests__/fetchSongstats.test.ts b/lib/songstats/__tests__/fetchSongstats.test.ts index 4760213d0..a60404d88 100644 --- a/lib/songstats/__tests__/fetchSongstats.test.ts +++ b/lib/songstats/__tests__/fetchSongstats.test.ts @@ -64,16 +64,17 @@ describe("fetchSongstats", () => { ); }); - it("returns a 500-compatible result when no SongStats API key is configured", async () => { + it("returns a sanitized 500-compatible result when no SongStats API key is configured", async () => { delete process.env.SONGSTATS_API_KEY; delete process.env.SongStats_API; const result = await fetchSongstats("/artists/search", { q: "Drake" }); expect(result.status).toBe(500); - expect(result.data).toEqual({ - error: "SONGSTATS_API_KEY or SongStats_API environment variable is not set", - }); + expect(result.data).toEqual({ error: "Internal server error" }); + expect(console.error).toHaveBeenCalledWith( + "[ERROR] fetchSongstats: SONGSTATS_API_KEY or SongStats_API environment variable is not set", + ); expect(fetch).not.toHaveBeenCalled(); }); diff --git a/lib/songstats/fetchSongstats.ts b/lib/songstats/fetchSongstats.ts index d929b6ce6..ba8f89147 100644 --- a/lib/songstats/fetchSongstats.ts +++ b/lib/songstats/fetchSongstats.ts @@ -1,5 +1,5 @@ import { SONGSTATS_BASE } from "@/lib/songstats/songstatsBase"; -import type { ProxyResult } from "@/lib/research/providers/ProxyResult"; +import type { ProxyResult } from "@/lib/research/ProxyResult"; const DEFAULT_SONGSTATS_TIMEOUT_MS = 10_000; const SLOW_SONGSTATS_TIMEOUT_MS = 35_000; @@ -55,8 +55,11 @@ export async function fetchSongstats( ): Promise { const apiKey = getSongstatsApiKey(); if (!apiKey) { + console.error( + "[ERROR] fetchSongstats: SONGSTATS_API_KEY or SongStats_API environment variable is not set", + ); return { - data: { error: "SONGSTATS_API_KEY or SongStats_API environment variable is not set" }, + data: { error: "Internal server error" }, status: 500, }; } From a88ad2ee6ddb88836236b696b47964b3b9db22dc Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 4 Jun 2026 10:03:31 -0500 Subject: [PATCH 17/21] refactor(research): remove unsupported /research/rank endpoint (YAGNI) The data source (SongStats) has no artist-rank capability, so the route only ever returned 501. Per review on docs#233, delete it rather than document a perpetually-unsupported endpoint. - Delete app/api/research/rank/route.ts and getResearchRankHandler.ts. - Drop the dead `/artist/:id/artist-rank` branch from mapSongstatsArtistPath (path now falls through to UNSUPPORTED_RESULT) and remove the now-unused UNSUPPORTED_RESULT import. Docs side: recoupable/docs#233. Refs recoupable/chat#1777. --- app/api/research/rank/route.ts | 22 ------------- lib/research/getResearchRankHandler.ts | 33 ------------------- .../songstats/mapSongstatsArtistPath.ts | 6 ---- 3 files changed, 61 deletions(-) delete mode 100644 app/api/research/rank/route.ts delete mode 100644 lib/research/getResearchRankHandler.ts diff --git a/app/api/research/rank/route.ts b/app/api/research/rank/route.ts deleted file mode 100644 index 87c1768df..000000000 --- a/app/api/research/rank/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getResearchRankHandler } from "@/lib/research/getResearchRankHandler"; - -/** - * OPTIONS /api/research/rank — CORS preflight. - * - * @returns CORS-enabled 200 response - */ -export async function OPTIONS() { - return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); -} - -/** - * GET /api/research/rank — Artist's global ranking data. Requires `?artist=` query param. - * - * @param request - must include `artist` query param - * @returns JSON ranking data or error - */ -export async function GET(request: NextRequest) { - return getResearchRankHandler(request); -} diff --git a/lib/research/getResearchRankHandler.ts b/lib/research/getResearchRankHandler.ts deleted file mode 100644 index 1914d316b..000000000 --- a/lib/research/getResearchRankHandler.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { validateArtistRequest } from "@/lib/research/validateArtistRequest"; -import { handleArtistResearch } from "@/lib/research/handleArtistResearch"; -import { successResponse } from "@/lib/networking/successResponse"; -import { errorResponse } from "@/lib/networking/errorResponse"; - -/** - * GET /api/research/rank - * - * Returns the artist's global ranking when the configured provider supplies one. - * - * @param request - The incoming HTTP request. - * @returns The JSON response. - */ -export async function getResearchRankHandler(request: NextRequest): Promise { - try { - const validated = await validateArtistRequest(request); - if (validated instanceof NextResponse) return validated; - const result = await handleArtistResearch({ - ...validated, - path: cmId => `/artist/${cmId}/artist-rank`, - }); - - if ("error" in result) return errorResponse(result.error, result.status); - return successResponse({ - rank: (result.data as Record)?.artist_rank || null, - }); - } catch (error) { - console.error("[ERROR] getResearchRankHandler:", error); - return errorResponse("Internal error", 500); - } -} diff --git a/lib/research/songstats/mapSongstatsArtistPath.ts b/lib/research/songstats/mapSongstatsArtistPath.ts index 86a7886d5..d8270a85b 100644 --- a/lib/research/songstats/mapSongstatsArtistPath.ts +++ b/lib/research/songstats/mapSongstatsArtistPath.ts @@ -5,7 +5,6 @@ import { normalizeArtistObject, normalizeArtistRecord, normalizeUrlMap, - UNSUPPORTED_RESULT, } from "@/lib/research/songstats/songstatsResearchMapping"; const SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM: Record = { @@ -127,11 +126,6 @@ export function mapSongstatsArtistPath( if (match) { return mapSongstatsResult("/artists/info", { songstats_artist_id: match[1] }, normalizeUrlMap); } - match = path.match(/^\/artist\/([^/]+)\/artist-rank$/); - if (match) { - return Promise.resolve(UNSUPPORTED_RESULT); - } - match = path.match(/^\/artist\/([^/]+)\/similar-artists\/by-configurations$/); if (match) { const limit = parsePositiveLimit(query?.limit); From 3212c9ebae2bb7cbc67a04ae48c002cfb743088e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 4 Jun 2026 10:51:49 -0500 Subject: [PATCH 18/21] fix(research): return real placements from /research/playlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SongStats `/artists/top_playlists` returns a metric *descriptor* (`{source, metric, scope, scope_options}`) unless a `scope` is given, and nests the actual rows under `data[].top_playlists`. The handler was sending the legacy `status` param (ignored) and surfacing the descriptor, so the endpoint returned no placements. - Map status -> SongStats scope (current -> current, past -> total). - Add `normalizeTopPlaylists` to flatten `data[].top_playlists` into the placement list. - Treat `artists/top_playlists` + `tracks/top_playlists` as slow paths (35s upstream timeout) and set `maxDuration=60` on the playlists route — the scoped call is slow and was 504ing under the 10s default. - Drop the dead Chartmetric-only filter params (editorial/indie/majorCurator/ popularIndie/personalized/chart, sort, since) from the handler; keep limit. Verified on preview: Drake/spotify now returns Today's Top Hits, Top 50 Global, RapCaviar, … with {playlist_id, playlist_name, external_url, followers_total, image_url}. Refs recoupable/chat#1777. --- app/api/research/playlists/route.ts | 3 ++ lib/research/getResearchPlaylistsHandler.ts | 28 +---------- .../__tests__/fetchSongstatsResearch.test.ts | 50 +++++++++++++++++++ .../songstats/mapSongstatsArtistPath.ts | 7 ++- .../songstats/songstatsResearchMapping.ts | 11 ++++ lib/songstats/fetchSongstats.ts | 2 + 6 files changed, 73 insertions(+), 28 deletions(-) diff --git a/app/api/research/playlists/route.ts b/app/api/research/playlists/route.ts index 1df1615bc..ab7ce2c64 100644 --- a/app/api/research/playlists/route.ts +++ b/app/api/research/playlists/route.ts @@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getResearchPlaylistsHandler } from "@/lib/research/getResearchPlaylistsHandler"; +// SongStats top_playlists (scope=current) is a slow stat call; allow up to 60s. +export const maxDuration = 60; + /** * OPTIONS /api/research/playlists — CORS preflight. * diff --git a/lib/research/getResearchPlaylistsHandler.ts b/lib/research/getResearchPlaylistsHandler.ts index b2f486fa5..370663a50 100644 --- a/lib/research/getResearchPlaylistsHandler.ts +++ b/lib/research/getResearchPlaylistsHandler.ts @@ -6,7 +6,8 @@ import { successResponse } from "@/lib/networking/successResponse"; import { errorResponse } from "@/lib/networking/errorResponse"; /** - * Playlists handler — returns playlists featuring an artist. Supports `?platform=`, `?status=`, `?limit=`, `?sort=`, `?since=`, and playlist-type filters. + * Playlists handler — returns the playlists an artist currently appears on for + * a given `?platform=`. Supports `?status=` (current|past) and `?limit=`. * * @param request - must include `artist` query param * @returns JSON playlist placements or error @@ -21,31 +22,6 @@ export async function getResearchPlaylistsHandler(request: NextRequest): Promise const query: Record = {}; const limit = searchParams.get("limit"); if (limit) query.limit = limit; - const sort = searchParams.get("sort"); - if (sort) query.sortColumn = sort; - const since = searchParams.get("since"); - if (since) query.since = since; - - const hasFilters = - searchParams.get("editorial") || - searchParams.get("indie") || - searchParams.get("majorCurator") || - searchParams.get("popularIndie") || - searchParams.get("personalized") || - searchParams.get("chart"); - if (hasFilters) { - if (searchParams.get("editorial")) query.editorial = searchParams.get("editorial")!; - if (searchParams.get("indie")) query.indie = searchParams.get("indie")!; - if (searchParams.get("majorCurator")) query.majorCurator = searchParams.get("majorCurator")!; - if (searchParams.get("popularIndie")) query.popularIndie = searchParams.get("popularIndie")!; - if (searchParams.get("personalized")) query.personalized = searchParams.get("personalized")!; - if (searchParams.get("chart")) query.chart = searchParams.get("chart")!; - } else { - query.editorial = "true"; - query.indie = "true"; - query.majorCurator = "true"; - query.popularIndie = "true"; - } const { platform, status, ...rest } = validated; const result = await handleArtistResearch({ diff --git a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts index 477463033..47b0d2b96 100644 --- a/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts +++ b/lib/research/songstats/__tests__/fetchSongstatsResearch.test.ts @@ -152,4 +152,54 @@ describe("fetchSongstatsResearch", () => { data: [{ id: "artist_2", songstats_artist_id: "artist_2", name: "Kendrick Lamar" }], }); }); + + it("maps current artist playlists to top_playlists with scope=current and flattens placements", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ + status: 200, + data: { + result: "success", + data: [ + { + source: "spotify", + metric: "top_playlists", + scope: "current", + top_playlists: [ + { playlist_id: "p1", playlist_name: "Today's Top Hits" }, + { playlist_id: "p2", playlist_name: "RapCaviar" }, + ], + }, + ], + }, + }); + + const result = await fetchSongstatsResearch("/artist/artist_1/spotify/current/playlists"); + + expect(fetchSongstats).toHaveBeenCalledWith("/artists/top_playlists", { + songstats_artist_id: "artist_1", + source: "spotify", + scope: "current", + }); + expect(result).toEqual({ + status: 200, + data: [ + { playlist_id: "p1", playlist_name: "Today's Top Hits" }, + { playlist_id: "p2", playlist_name: "RapCaviar" }, + ], + }); + }); + + it("maps past artist playlists to top_playlists with scope=total", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ + status: 200, + data: { result: "success", data: [{ source: "spotify", top_playlists: [] }] }, + }); + + await fetchSongstatsResearch("/artist/artist_1/spotify/past/playlists"); + + expect(fetchSongstats).toHaveBeenCalledWith("/artists/top_playlists", { + songstats_artist_id: "artist_1", + source: "spotify", + scope: "total", + }); + }); }); diff --git a/lib/research/songstats/mapSongstatsArtistPath.ts b/lib/research/songstats/mapSongstatsArtistPath.ts index d8270a85b..04f22b361 100644 --- a/lib/research/songstats/mapSongstatsArtistPath.ts +++ b/lib/research/songstats/mapSongstatsArtistPath.ts @@ -4,6 +4,7 @@ import { mapSongstatsResult, normalizeArtistObject, normalizeArtistRecord, + normalizeTopPlaylists, normalizeUrlMap, } from "@/lib/research/songstats/songstatsResearchMapping"; @@ -139,15 +140,17 @@ export function mapSongstatsArtistPath( match = path.match(/^\/artist\/([^/]+)\/([^/]+)\/(current|past)\/playlists$/); if (match) { + // SongStats top_playlists is scoped by a time window, not the legacy + // current/past status: map current -> current placements, past -> all-time. return mapSongstatsResult( "/artists/top_playlists", { songstats_artist_id: match[1], source: match[2], - status: match[3], + scope: match[3] === "past" ? "total" : "current", ...query, }, - data => extractList(data, ["playlists", "results", "data", "items"]), + normalizeTopPlaylists, ); } diff --git a/lib/research/songstats/songstatsResearchMapping.ts b/lib/research/songstats/songstatsResearchMapping.ts index 83d205d3b..d9491ca86 100644 --- a/lib/research/songstats/songstatsResearchMapping.ts +++ b/lib/research/songstats/songstatsResearchMapping.ts @@ -33,6 +33,17 @@ export function extractList(value: unknown, keys: string[]): unknown[] { return []; } +/** + * Flattens SongStats `/artists/top_playlists` (and `/tracks/top_playlists`) + * into a single placement list. The provider nests the rows one level deep: + * `{ data: [{ source, scope, top_playlists: [...] }] }` — one entry per source. + */ +export function normalizeTopPlaylists(value: unknown): unknown[] { + return extractList(value, ["data"]).flatMap(entry => + isRecord(entry) && Array.isArray(entry.top_playlists) ? entry.top_playlists : [], + ); +} + function pickString(record: JsonRecord, keys: string[]): string | undefined { for (const key of keys) { const value = record[key]; diff --git a/lib/songstats/fetchSongstats.ts b/lib/songstats/fetchSongstats.ts index ba8f89147..5ac6a9332 100644 --- a/lib/songstats/fetchSongstats.ts +++ b/lib/songstats/fetchSongstats.ts @@ -6,8 +6,10 @@ const SLOW_SONGSTATS_TIMEOUT_MS = 35_000; const SLOW_SONGSTATS_PATHS = new Set([ "artists/historic_stats", "artists/stats", + "artists/top_playlists", "tracks/historic_stats", "tracks/stats", + "tracks/top_playlists", ]); function appendQueryParams(url: URL, queryParams?: Record): void { From 8c8bc15e4a3824721f547d08a5057f52fb273168 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 4 Jun 2026 11:49:48 -0500 Subject: [PATCH 19/21] perf(research): raise slow SongStats timeout 35s->50s to cut premature 504s top_playlists/stats calls run ~30-45s on routes with maxDuration=60 but were aborted at 35s, 504ing before the function budget was used. Use most of the 60s budget upstream. Drop the unused tracks/top_playlists slow-path entry (track/playlists maps to tracks/activities). --- lib/songstats/__tests__/fetchSongstats.test.ts | 2 +- lib/songstats/fetchSongstats.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/songstats/__tests__/fetchSongstats.test.ts b/lib/songstats/__tests__/fetchSongstats.test.ts index a60404d88..8fe8bdca7 100644 --- a/lib/songstats/__tests__/fetchSongstats.test.ts +++ b/lib/songstats/__tests__/fetchSongstats.test.ts @@ -137,7 +137,7 @@ describe("fetchSongstats", () => { expect(abortCount).toBe(0); - await vi.advanceTimersByTimeAsync(25_000); + await vi.advanceTimersByTimeAsync(40_000); expect(abortCount).toBe(1); await expect(resultPromise).resolves.toEqual({ diff --git a/lib/songstats/fetchSongstats.ts b/lib/songstats/fetchSongstats.ts index 5ac6a9332..adb2aa23c 100644 --- a/lib/songstats/fetchSongstats.ts +++ b/lib/songstats/fetchSongstats.ts @@ -2,14 +2,15 @@ import { SONGSTATS_BASE } from "@/lib/songstats/songstatsBase"; import type { ProxyResult } from "@/lib/research/ProxyResult"; const DEFAULT_SONGSTATS_TIMEOUT_MS = 10_000; -const SLOW_SONGSTATS_TIMEOUT_MS = 35_000; +// Slow SongStats stat calls run on routes with maxDuration=60; allow most of +// that budget upstream before aborting so they don't 504 prematurely. +const SLOW_SONGSTATS_TIMEOUT_MS = 50_000; const SLOW_SONGSTATS_PATHS = new Set([ "artists/historic_stats", "artists/stats", "artists/top_playlists", "tracks/historic_stats", "tracks/stats", - "tracks/top_playlists", ]); function appendQueryParams(url: URL, queryParams?: Record): void { From 5566a9d604f3780a7eee70c5711c1d06aa148173 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 4 Jun 2026 12:02:29 -0500 Subject: [PATCH 20/21] refactor(research): extract mapEntitySearch to its own file (SRP) Addresses review on api#635: move mapEntitySearch out of fetchSongstatsResearch.ts into lib/research/songstats/mapEntitySearch.ts so each file exports a single function (matching mapSongstatsArtistPath / mapSongstatsTrackPath). No behavior change. --- .../songstats/fetchSongstatsResearch.ts | 40 +---------------- lib/research/songstats/mapEntitySearch.ts | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 38 deletions(-) create mode 100644 lib/research/songstats/mapEntitySearch.ts diff --git a/lib/research/songstats/fetchSongstatsResearch.ts b/lib/research/songstats/fetchSongstatsResearch.ts index 8c27408ff..002bd6e00 100644 --- a/lib/research/songstats/fetchSongstatsResearch.ts +++ b/lib/research/songstats/fetchSongstatsResearch.ts @@ -1,44 +1,8 @@ import type { ProxyResult } from "@/lib/research/ProxyResult"; +import { mapEntitySearch } from "@/lib/research/songstats/mapEntitySearch"; import { mapSongstatsArtistPath } from "@/lib/research/songstats/mapSongstatsArtistPath"; import { mapSongstatsTrackPath } from "@/lib/research/songstats/mapSongstatsTrackPath"; -import { - extractList, - mapSongstatsResult, - normalizeArtistRecord, - normalizeTrackRecord, - UNSUPPORTED_RESULT, - withoutLegacySearchParams, -} from "@/lib/research/songstats/songstatsResearchMapping"; - -function mapEntitySearch( - path: string, - query?: Record, -): Promise | null { - if (path !== "/search") return null; - - const type = (query?.type || "artists").toLowerCase(); - if (type === "artists" || type === "artist") { - return mapSongstatsResult("/artists/search", withoutLegacySearchParams(query), data => ({ - artists: extractList(data, ["artists", "results", "data", "items"]).map( - normalizeArtistRecord, - ), - })); - } - - if (type === "tracks" || type === "track") { - return mapSongstatsResult("/tracks/search", withoutLegacySearchParams(query), data => ({ - tracks: extractList(data, ["tracks", "results", "data", "items"]).map(normalizeTrackRecord), - })); - } - - if (type === "labels" || type === "label") { - return mapSongstatsResult("/labels/search", withoutLegacySearchParams(query), data => ({ - labels: extractList(data, ["labels", "results", "data", "items"]), - })); - } - - return Promise.resolve(UNSUPPORTED_RESULT); -} +import { UNSUPPORTED_RESULT } from "@/lib/research/songstats/songstatsResearchMapping"; export async function fetchSongstatsResearch( path: string, diff --git a/lib/research/songstats/mapEntitySearch.ts b/lib/research/songstats/mapEntitySearch.ts new file mode 100644 index 000000000..8ebc5cefb --- /dev/null +++ b/lib/research/songstats/mapEntitySearch.ts @@ -0,0 +1,44 @@ +import type { ProxyResult } from "@/lib/research/ProxyResult"; +import { + extractList, + mapSongstatsResult, + normalizeArtistRecord, + normalizeTrackRecord, + UNSUPPORTED_RESULT, + withoutLegacySearchParams, +} from "@/lib/research/songstats/songstatsResearchMapping"; + +/** + * Maps `/search` to the matching SongStats search endpoint by `type` + * (artists | tracks | labels). Returns an explicit unsupported result for any + * other type, and `null` for non-search paths so callers can try other mappers. + */ +export function mapEntitySearch( + path: string, + query?: Record, +): Promise | null { + if (path !== "/search") return null; + + const type = (query?.type || "artists").toLowerCase(); + if (type === "artists" || type === "artist") { + return mapSongstatsResult("/artists/search", withoutLegacySearchParams(query), data => ({ + artists: extractList(data, ["artists", "results", "data", "items"]).map( + normalizeArtistRecord, + ), + })); + } + + if (type === "tracks" || type === "track") { + return mapSongstatsResult("/tracks/search", withoutLegacySearchParams(query), data => ({ + tracks: extractList(data, ["tracks", "results", "data", "items"]).map(normalizeTrackRecord), + })); + } + + if (type === "labels" || type === "label") { + return mapSongstatsResult("/labels/search", withoutLegacySearchParams(query), data => ({ + labels: extractList(data, ["labels", "results", "data", "items"]), + })); + } + + return Promise.resolve(UNSUPPORTED_RESULT); +} From e23888500561c8dd611bdc617946ef796d61f1a9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 4 Jun 2026 12:20:22 -0500 Subject: [PATCH 21/21] refactor(research): one exported function per file (SRP); drop legacy SongStats_API Addresses review on api#635: - Split lib/songstats/fetchSongstats.ts helpers (appendQueryParams, parseSongstatsResponse, getDefaultSongstatsTimeoutMs, getSongstatsTimeoutMs, isAbortError, getSongstatsApiKey) into their own files. - Split lib/research/songstats/songstatsResearchMapping.ts into one file per function (isRecord/JsonRecord, extractList, firstRecord, pickString, normalize*, mapSongstatsResult, withoutLegacySearchParams, normalizeTopPlaylists, unsupportedResult) and delete the barrel. - Split mapArtistAudienceSource/mapArtistStatsSource/parsePositiveLimit out of mapSongstatsArtistPath.ts; extract extractProviderTrackId out of resolveTrack.ts. - Drop the legacy SongStats_API env fallback (getSongstatsApiKey, error message, .env.example, test). SONGSTATS_API_KEY is the only key. No behavior change. 147 tests pass, lint + tsc clean. --- .env.example | 2 - lib/research/extractProviderTrackId.ts | 15 ++ lib/research/resolveTrack.ts | 14 +- lib/research/songstats/extractList.ts | 20 +++ .../songstats/fetchSongstatsResearch.ts | 2 +- lib/research/songstats/firstRecord.ts | 9 ++ lib/research/songstats/isRecord.ts | 8 + .../songstats/mapArtistAudienceSource.ts | 23 +++ .../songstats/mapArtistStatsSource.ts | 22 +++ lib/research/songstats/mapEntitySearch.ts | 14 +- .../songstats/mapSongstatsArtistPath.ts | 65 ++------ lib/research/songstats/mapSongstatsResult.ts | 15 ++ .../songstats/mapSongstatsTrackPath.ts | 10 +- .../songstats/normalizeArtistObject.ts | 11 ++ .../songstats/normalizeArtistRecord.ts | 12 ++ .../songstats/normalizeTopPlaylists.ts | 13 ++ .../songstats/normalizeTrackLookupObject.ts | 19 +++ .../songstats/normalizeTrackObject.ts | 13 ++ .../songstats/normalizeTrackRecord.ts | 12 ++ lib/research/songstats/normalizeUrlMap.ts | 36 +++++ lib/research/songstats/parsePositiveLimit.ts | 9 ++ lib/research/songstats/pickString.ts | 13 ++ .../songstats/songstatsResearchMapping.ts | 145 ------------------ lib/research/songstats/unsupportedResult.ts | 6 + .../songstats/withoutLegacySearchParams.ts | 10 ++ .../__tests__/fetchSongstats.test.ts | 25 +-- lib/songstats/appendQueryParams.ts | 12 ++ lib/songstats/fetchSongstats.ts | 60 +------- lib/songstats/getDefaultSongstatsTimeoutMs.ts | 20 +++ lib/songstats/getSongstatsApiKey.ts | 6 + lib/songstats/getSongstatsTimeoutMs.ts | 11 ++ lib/songstats/isAbortError.ts | 6 + lib/songstats/parseSongstatsResponse.ts | 10 ++ 33 files changed, 359 insertions(+), 309 deletions(-) create mode 100644 lib/research/extractProviderTrackId.ts create mode 100644 lib/research/songstats/extractList.ts create mode 100644 lib/research/songstats/firstRecord.ts create mode 100644 lib/research/songstats/isRecord.ts create mode 100644 lib/research/songstats/mapArtistAudienceSource.ts create mode 100644 lib/research/songstats/mapArtistStatsSource.ts create mode 100644 lib/research/songstats/mapSongstatsResult.ts create mode 100644 lib/research/songstats/normalizeArtistObject.ts create mode 100644 lib/research/songstats/normalizeArtistRecord.ts create mode 100644 lib/research/songstats/normalizeTopPlaylists.ts create mode 100644 lib/research/songstats/normalizeTrackLookupObject.ts create mode 100644 lib/research/songstats/normalizeTrackObject.ts create mode 100644 lib/research/songstats/normalizeTrackRecord.ts create mode 100644 lib/research/songstats/normalizeUrlMap.ts create mode 100644 lib/research/songstats/parsePositiveLimit.ts create mode 100644 lib/research/songstats/pickString.ts delete mode 100644 lib/research/songstats/songstatsResearchMapping.ts create mode 100644 lib/research/songstats/unsupportedResult.ts create mode 100644 lib/research/songstats/withoutLegacySearchParams.ts create mode 100644 lib/songstats/appendQueryParams.ts create mode 100644 lib/songstats/getDefaultSongstatsTimeoutMs.ts create mode 100644 lib/songstats/getSongstatsApiKey.ts create mode 100644 lib/songstats/getSongstatsTimeoutMs.ts create mode 100644 lib/songstats/isAbortError.ts create mode 100644 lib/songstats/parseSongstatsResponse.ts diff --git a/.env.example b/.env.example index 6cc35f631..9d6de84d9 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,6 @@ PERPLEXITY_API_KEY= SERPAPI_API_KEY= ANTHROPIC_API_KEY= SONGSTATS_API_KEY= -# Legacy alias also supported: -SongStats_API= # Spotify SPOTIFY_CLIENT_ID= diff --git a/lib/research/extractProviderTrackId.ts b/lib/research/extractProviderTrackId.ts new file mode 100644 index 000000000..d74971eca --- /dev/null +++ b/lib/research/extractProviderTrackId.ts @@ -0,0 +1,15 @@ +interface GetIdsResponse { + songstats_track_ids?: string[]; + songstats_track_id?: string; + id?: string | number; +} + +/** + * Extracts a provider track ID from a get-ids response payload, if present. + */ +export function extractProviderTrackId(data: unknown): string | undefined { + const ids = (Array.isArray(data) ? data[0] : data) as GetIdsResponse | undefined; + const id = ids?.songstats_track_ids?.[0] ?? ids?.songstats_track_id ?? ids?.id; + + return id === undefined || id === null || id === "" ? undefined : String(id); +} diff --git a/lib/research/resolveTrack.ts b/lib/research/resolveTrack.ts index 02b297f5b..df1a9e5bc 100644 --- a/lib/research/resolveTrack.ts +++ b/lib/research/resolveTrack.ts @@ -1,19 +1,7 @@ import generateAccessToken from "@/lib/spotify/generateAccessToken"; import getSearch from "@/lib/spotify/getSearch"; import { handleResearch } from "@/lib/research/handleResearch"; - -interface GetIdsResponse { - songstats_track_ids?: string[]; - songstats_track_id?: string; - id?: string | number; -} - -function extractProviderTrackId(data: unknown): string | undefined { - const ids = (Array.isArray(data) ? data[0] : data) as GetIdsResponse | undefined; - const id = ids?.songstats_track_ids?.[0] ?? ids?.songstats_track_id ?? ids?.id; - - return id === undefined || id === null || id === "" ? undefined : String(id); -} +import { extractProviderTrackId } from "@/lib/research/extractProviderTrackId"; /** * Resolves a track name (+ optional artist) to a provider track ID. diff --git a/lib/research/songstats/extractList.ts b/lib/research/songstats/extractList.ts new file mode 100644 index 000000000..130342d1e --- /dev/null +++ b/lib/research/songstats/extractList.ts @@ -0,0 +1,20 @@ +import { isRecord } from "@/lib/research/songstats/isRecord"; + +/** + * Extracts the first array found at the given keys, searching nested records. + */ +export function extractList(value: unknown, keys: string[]): unknown[] { + if (Array.isArray(value)) return value; + if (!isRecord(value)) return []; + + for (const key of keys) { + const child = value[key]; + if (Array.isArray(child)) return child; + if (isRecord(child)) { + const nested = extractList(child, keys); + if (nested.length) return nested; + } + } + + return []; +} diff --git a/lib/research/songstats/fetchSongstatsResearch.ts b/lib/research/songstats/fetchSongstatsResearch.ts index 002bd6e00..9b6724f8a 100644 --- a/lib/research/songstats/fetchSongstatsResearch.ts +++ b/lib/research/songstats/fetchSongstatsResearch.ts @@ -2,7 +2,7 @@ import type { ProxyResult } from "@/lib/research/ProxyResult"; import { mapEntitySearch } from "@/lib/research/songstats/mapEntitySearch"; import { mapSongstatsArtistPath } from "@/lib/research/songstats/mapSongstatsArtistPath"; import { mapSongstatsTrackPath } from "@/lib/research/songstats/mapSongstatsTrackPath"; -import { UNSUPPORTED_RESULT } from "@/lib/research/songstats/songstatsResearchMapping"; +import { UNSUPPORTED_RESULT } from "@/lib/research/songstats/unsupportedResult"; export async function fetchSongstatsResearch( path: string, diff --git a/lib/research/songstats/firstRecord.ts b/lib/research/songstats/firstRecord.ts new file mode 100644 index 000000000..2a72e47ad --- /dev/null +++ b/lib/research/songstats/firstRecord.ts @@ -0,0 +1,9 @@ +import { isRecord, type JsonRecord } from "@/lib/research/songstats/isRecord"; + +/** + * Returns the first record from a value, unwrapping a single-element array. + */ +export function firstRecord(value: unknown): JsonRecord | null { + if (Array.isArray(value)) return isRecord(value[0]) ? value[0] : null; + return isRecord(value) ? value : null; +} diff --git a/lib/research/songstats/isRecord.ts b/lib/research/songstats/isRecord.ts new file mode 100644 index 000000000..489d844a2 --- /dev/null +++ b/lib/research/songstats/isRecord.ts @@ -0,0 +1,8 @@ +export type JsonRecord = Record; + +/** + * Type guard for a plain (non-array) object record. + */ +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/lib/research/songstats/mapArtistAudienceSource.ts b/lib/research/songstats/mapArtistAudienceSource.ts new file mode 100644 index 000000000..7c476fd3d --- /dev/null +++ b/lib/research/songstats/mapArtistAudienceSource.ts @@ -0,0 +1,23 @@ +const SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM: Record = { + bandsintown: "bandsintown_followers", + deezer: "deezer_fans", + facebook: "facebook_likes", + instagram: "instagram_followers", + line: "line_followers", + melon: "melon_followers", + soundcloud: "soundcloud_followers", + spotify: "spotify_streams", + tiktok: "tiktok_followers", + twitch: "twitch_followers", + twitter: "twitter_followers", + wikipedia: "wikipedia_views", + youtube_artist: "youtube_artist_subscribers", + youtube_channel: "youtube_channel_subscribers", +}; + +/** + * Maps a legacy audience platform to its SongStats audience metric source. + */ +export function mapArtistAudienceSource(source: string): string { + return SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM[source] || source; +} diff --git a/lib/research/songstats/mapArtistStatsSource.ts b/lib/research/songstats/mapArtistStatsSource.ts new file mode 100644 index 000000000..1df5908a0 --- /dev/null +++ b/lib/research/songstats/mapArtistStatsSource.ts @@ -0,0 +1,22 @@ +const SONGSTATS_ARTIST_STATS_SOURCE_BY_PLATFORM: Record = { + amazon: "amazon", + bandsintown: "bandsintown", + deezer: "deezer", + facebook: "facebook", + instagram: "instagram", + radio: "radio", + soundcloud: "soundcloud", + spotify: "spotify", + sxm: "sxm", + tiktok: "tiktok", + twitter: "twitter", + youtube_artist: "youtube", + youtube_channel: "youtube", +}; + +/** + * Maps a legacy stats platform to its SongStats artist stats source. + */ +export function mapArtistStatsSource(source: string): string { + return SONGSTATS_ARTIST_STATS_SOURCE_BY_PLATFORM[source] || source; +} diff --git a/lib/research/songstats/mapEntitySearch.ts b/lib/research/songstats/mapEntitySearch.ts index 8ebc5cefb..f0b76eb72 100644 --- a/lib/research/songstats/mapEntitySearch.ts +++ b/lib/research/songstats/mapEntitySearch.ts @@ -1,12 +1,10 @@ import type { ProxyResult } from "@/lib/research/ProxyResult"; -import { - extractList, - mapSongstatsResult, - normalizeArtistRecord, - normalizeTrackRecord, - UNSUPPORTED_RESULT, - withoutLegacySearchParams, -} from "@/lib/research/songstats/songstatsResearchMapping"; +import { extractList } from "@/lib/research/songstats/extractList"; +import { mapSongstatsResult } from "@/lib/research/songstats/mapSongstatsResult"; +import { normalizeArtistRecord } from "@/lib/research/songstats/normalizeArtistRecord"; +import { normalizeTrackRecord } from "@/lib/research/songstats/normalizeTrackRecord"; +import { UNSUPPORTED_RESULT } from "@/lib/research/songstats/unsupportedResult"; +import { withoutLegacySearchParams } from "@/lib/research/songstats/withoutLegacySearchParams"; /** * Maps `/search` to the matching SongStats search endpoint by `type` diff --git a/lib/research/songstats/mapSongstatsArtistPath.ts b/lib/research/songstats/mapSongstatsArtistPath.ts index 04f22b361..0d4770512 100644 --- a/lib/research/songstats/mapSongstatsArtistPath.ts +++ b/lib/research/songstats/mapSongstatsArtistPath.ts @@ -1,60 +1,13 @@ import type { ProxyResult } from "@/lib/research/ProxyResult"; -import { - extractList, - mapSongstatsResult, - normalizeArtistObject, - normalizeArtistRecord, - normalizeTopPlaylists, - normalizeUrlMap, -} from "@/lib/research/songstats/songstatsResearchMapping"; - -const SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM: Record = { - bandsintown: "bandsintown_followers", - deezer: "deezer_fans", - facebook: "facebook_likes", - instagram: "instagram_followers", - line: "line_followers", - melon: "melon_followers", - soundcloud: "soundcloud_followers", - spotify: "spotify_streams", - tiktok: "tiktok_followers", - twitch: "twitch_followers", - twitter: "twitter_followers", - wikipedia: "wikipedia_views", - youtube_artist: "youtube_artist_subscribers", - youtube_channel: "youtube_channel_subscribers", -}; - -const SONGSTATS_ARTIST_STATS_SOURCE_BY_PLATFORM: Record = { - amazon: "amazon", - bandsintown: "bandsintown", - deezer: "deezer", - facebook: "facebook", - instagram: "instagram", - radio: "radio", - soundcloud: "soundcloud", - spotify: "spotify", - sxm: "sxm", - tiktok: "tiktok", - twitter: "twitter", - youtube_artist: "youtube", - youtube_channel: "youtube", -}; - -function mapArtistAudienceSource(source: string): string { - return SONGSTATS_ARTIST_METRIC_SOURCE_BY_PLATFORM[source] || source; -} - -function mapArtistStatsSource(source: string): string { - return SONGSTATS_ARTIST_STATS_SOURCE_BY_PLATFORM[source] || source; -} - -function parsePositiveLimit(value?: string): number | undefined { - if (!value) return undefined; - - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; -} +import { extractList } from "@/lib/research/songstats/extractList"; +import { mapSongstatsResult } from "@/lib/research/songstats/mapSongstatsResult"; +import { normalizeArtistObject } from "@/lib/research/songstats/normalizeArtistObject"; +import { normalizeArtistRecord } from "@/lib/research/songstats/normalizeArtistRecord"; +import { normalizeTopPlaylists } from "@/lib/research/songstats/normalizeTopPlaylists"; +import { normalizeUrlMap } from "@/lib/research/songstats/normalizeUrlMap"; +import { mapArtistAudienceSource } from "@/lib/research/songstats/mapArtistAudienceSource"; +import { mapArtistStatsSource } from "@/lib/research/songstats/mapArtistStatsSource"; +import { parsePositiveLimit } from "@/lib/research/songstats/parsePositiveLimit"; export function mapSongstatsArtistPath( path: string, diff --git a/lib/research/songstats/mapSongstatsResult.ts b/lib/research/songstats/mapSongstatsResult.ts new file mode 100644 index 000000000..711a5d99a --- /dev/null +++ b/lib/research/songstats/mapSongstatsResult.ts @@ -0,0 +1,15 @@ +import type { ProxyResult } from "@/lib/research/ProxyResult"; +import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; + +/** + * Fetches a SongStats endpoint and optionally normalizes a successful payload. + */ +export async function mapSongstatsResult( + endpoint: string, + query?: Record, + normalize?: (value: unknown) => unknown, +): Promise { + const result = await fetchSongstats(endpoint, query); + if (result.status !== 200 || !normalize) return result; + return { status: result.status, data: normalize(result.data) }; +} diff --git a/lib/research/songstats/mapSongstatsTrackPath.ts b/lib/research/songstats/mapSongstatsTrackPath.ts index 9a7ae6c0b..063a10ec0 100644 --- a/lib/research/songstats/mapSongstatsTrackPath.ts +++ b/lib/research/songstats/mapSongstatsTrackPath.ts @@ -1,10 +1,8 @@ import type { ProxyResult } from "@/lib/research/ProxyResult"; -import { - extractList, - mapSongstatsResult, - normalizeTrackLookupObject, - normalizeTrackObject, -} from "@/lib/research/songstats/songstatsResearchMapping"; +import { extractList } from "@/lib/research/songstats/extractList"; +import { mapSongstatsResult } from "@/lib/research/songstats/mapSongstatsResult"; +import { normalizeTrackLookupObject } from "@/lib/research/songstats/normalizeTrackLookupObject"; +import { normalizeTrackObject } from "@/lib/research/songstats/normalizeTrackObject"; export function mapSongstatsTrackPath( path: string, diff --git a/lib/research/songstats/normalizeArtistObject.ts b/lib/research/songstats/normalizeArtistObject.ts new file mode 100644 index 000000000..88cac881d --- /dev/null +++ b/lib/research/songstats/normalizeArtistObject.ts @@ -0,0 +1,11 @@ +import { firstRecord } from "@/lib/research/songstats/firstRecord"; +import { normalizeArtistRecord } from "@/lib/research/songstats/normalizeArtistRecord"; + +/** + * Unwraps and normalizes a single artist object from a SongStats payload. + */ +export function normalizeArtistObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + return normalizeArtistRecord(record); +} diff --git a/lib/research/songstats/normalizeArtistRecord.ts b/lib/research/songstats/normalizeArtistRecord.ts new file mode 100644 index 000000000..429393d8d --- /dev/null +++ b/lib/research/songstats/normalizeArtistRecord.ts @@ -0,0 +1,12 @@ +import { isRecord } from "@/lib/research/songstats/isRecord"; +import { pickString } from "@/lib/research/songstats/pickString"; + +/** + * Adds a normalized `id` to an artist record from its SongStats identifiers. + */ +export function normalizeArtistRecord(value: unknown): unknown { + if (!isRecord(value)) return value; + + const id = pickString(value, ["songstats_artist_id", "artist_id", "id"]); + return id ? { ...value, id } : value; +} diff --git a/lib/research/songstats/normalizeTopPlaylists.ts b/lib/research/songstats/normalizeTopPlaylists.ts new file mode 100644 index 000000000..48f4b095f --- /dev/null +++ b/lib/research/songstats/normalizeTopPlaylists.ts @@ -0,0 +1,13 @@ +import { extractList } from "@/lib/research/songstats/extractList"; +import { isRecord } from "@/lib/research/songstats/isRecord"; + +/** + * Flattens SongStats `/artists/top_playlists` (and `/tracks/top_playlists`) + * into a single placement list. The provider nests the rows one level deep: + * `{ data: [{ source, scope, top_playlists: [...] }] }` — one entry per source. + */ +export function normalizeTopPlaylists(value: unknown): unknown[] { + return extractList(value, ["data"]).flatMap(entry => + isRecord(entry) && Array.isArray(entry.top_playlists) ? entry.top_playlists : [], + ); +} diff --git a/lib/research/songstats/normalizeTrackLookupObject.ts b/lib/research/songstats/normalizeTrackLookupObject.ts new file mode 100644 index 000000000..4ee839b3a --- /dev/null +++ b/lib/research/songstats/normalizeTrackLookupObject.ts @@ -0,0 +1,19 @@ +import { firstRecord } from "@/lib/research/songstats/firstRecord"; +import { pickString } from "@/lib/research/songstats/pickString"; + +/** + * Normalizes a track lookup object, exposing both `id` and `songstats_track_ids`. + */ +export function normalizeTrackLookupObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + + const id = pickString(record, ["songstats_track_id", "track_id", "id"]); + if (!id) return record; + + return { + ...record, + id, + songstats_track_ids: [id], + }; +} diff --git a/lib/research/songstats/normalizeTrackObject.ts b/lib/research/songstats/normalizeTrackObject.ts new file mode 100644 index 000000000..65cf44147 --- /dev/null +++ b/lib/research/songstats/normalizeTrackObject.ts @@ -0,0 +1,13 @@ +import { firstRecord } from "@/lib/research/songstats/firstRecord"; +import { pickString } from "@/lib/research/songstats/pickString"; + +/** + * Unwraps and normalizes a single track object from a SongStats payload. + */ +export function normalizeTrackObject(value: unknown): unknown { + const record = firstRecord(value); + if (!record) return value; + + const id = pickString(record, ["songstats_track_id", "track_id", "id"]); + return id ? { ...record, id } : record; +} diff --git a/lib/research/songstats/normalizeTrackRecord.ts b/lib/research/songstats/normalizeTrackRecord.ts new file mode 100644 index 000000000..a816bb187 --- /dev/null +++ b/lib/research/songstats/normalizeTrackRecord.ts @@ -0,0 +1,12 @@ +import { isRecord } from "@/lib/research/songstats/isRecord"; +import { pickString } from "@/lib/research/songstats/pickString"; + +/** + * Adds a normalized `id` to a track record from its SongStats identifiers. + */ +export function normalizeTrackRecord(value: unknown): unknown { + if (!isRecord(value)) return value; + + const id = pickString(value, ["songstats_track_id", "track_id", "id"]); + return id ? { ...value, id } : value; +} diff --git a/lib/research/songstats/normalizeUrlMap.ts b/lib/research/songstats/normalizeUrlMap.ts new file mode 100644 index 000000000..7faad918a --- /dev/null +++ b/lib/research/songstats/normalizeUrlMap.ts @@ -0,0 +1,36 @@ +import { isRecord, type JsonRecord } from "@/lib/research/songstats/isRecord"; +import { pickString } from "@/lib/research/songstats/pickString"; + +/** + * Walks a value and collects platform-keyed URLs into a flat map. + */ +export function normalizeUrlMap(value: unknown): JsonRecord { + const urls: JsonRecord = {}; + + const visit = (current: unknown, keyHint?: string): void => { + if (typeof current === "string") { + if (/^https?:\/\//i.test(current)) urls[keyHint || current] = current; + return; + } + + if (Array.isArray(current)) { + for (const item of current) visit(item, keyHint); + return; + } + + if (!isRecord(current)) return; + + const platform = pickString(current, ["platform", "source", "type", "name", "domain"]); + const url = pickString(current, ["url", "link", "href"]); + if (url && /^https?:\/\//i.test(url)) { + urls[platform || url] = url; + } + + for (const [key, child] of Object.entries(current)) { + visit(child, key); + } + }; + + visit(value); + return urls; +} diff --git a/lib/research/songstats/parsePositiveLimit.ts b/lib/research/songstats/parsePositiveLimit.ts new file mode 100644 index 000000000..91098e659 --- /dev/null +++ b/lib/research/songstats/parsePositiveLimit.ts @@ -0,0 +1,9 @@ +/** + * Parses a positive integer limit from a string, or undefined when invalid. + */ +export function parsePositiveLimit(value?: string): number | undefined { + if (!value) return undefined; + + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} diff --git a/lib/research/songstats/pickString.ts b/lib/research/songstats/pickString.ts new file mode 100644 index 000000000..154ea68a7 --- /dev/null +++ b/lib/research/songstats/pickString.ts @@ -0,0 +1,13 @@ +import type { JsonRecord } from "@/lib/research/songstats/isRecord"; + +/** + * Returns the first key's value coerced to a string, or undefined. + */ +export function pickString(record: JsonRecord, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" || typeof value === "number") return String(value); + } + + return undefined; +} diff --git a/lib/research/songstats/songstatsResearchMapping.ts b/lib/research/songstats/songstatsResearchMapping.ts deleted file mode 100644 index d9491ca86..000000000 --- a/lib/research/songstats/songstatsResearchMapping.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { ProxyResult } from "@/lib/research/ProxyResult"; -import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; - -export type JsonRecord = Record; - -export const UNSUPPORTED_RESULT: ProxyResult = { - status: 501, - data: { error: "Research data source does not support this endpoint" }, -}; - -export function isRecord(value: unknown): value is JsonRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function firstRecord(value: unknown): JsonRecord | null { - if (Array.isArray(value)) return isRecord(value[0]) ? value[0] : null; - return isRecord(value) ? value : null; -} - -export function extractList(value: unknown, keys: string[]): unknown[] { - if (Array.isArray(value)) return value; - if (!isRecord(value)) return []; - - for (const key of keys) { - const child = value[key]; - if (Array.isArray(child)) return child; - if (isRecord(child)) { - const nested = extractList(child, keys); - if (nested.length) return nested; - } - } - - return []; -} - -/** - * Flattens SongStats `/artists/top_playlists` (and `/tracks/top_playlists`) - * into a single placement list. The provider nests the rows one level deep: - * `{ data: [{ source, scope, top_playlists: [...] }] }` — one entry per source. - */ -export function normalizeTopPlaylists(value: unknown): unknown[] { - return extractList(value, ["data"]).flatMap(entry => - isRecord(entry) && Array.isArray(entry.top_playlists) ? entry.top_playlists : [], - ); -} - -function pickString(record: JsonRecord, keys: string[]): string | undefined { - for (const key of keys) { - const value = record[key]; - if (typeof value === "string" || typeof value === "number") return String(value); - } - - return undefined; -} - -export function normalizeArtistRecord(value: unknown): unknown { - if (!isRecord(value)) return value; - - const id = pickString(value, ["songstats_artist_id", "artist_id", "id"]); - return id ? { ...value, id } : value; -} - -export function normalizeTrackRecord(value: unknown): unknown { - if (!isRecord(value)) return value; - - const id = pickString(value, ["songstats_track_id", "track_id", "id"]); - return id ? { ...value, id } : value; -} - -export function normalizeArtistObject(value: unknown): unknown { - const record = firstRecord(value); - if (!record) return value; - return normalizeArtistRecord(record); -} - -export function normalizeTrackObject(value: unknown): unknown { - const record = firstRecord(value); - if (!record) return value; - - const id = pickString(record, ["songstats_track_id", "track_id", "id"]); - return id ? { ...record, id } : record; -} - -export function normalizeTrackLookupObject(value: unknown): unknown { - const record = firstRecord(value); - if (!record) return value; - - const id = pickString(record, ["songstats_track_id", "track_id", "id"]); - if (!id) return record; - - return { - ...record, - id, - songstats_track_ids: [id], - }; -} - -export function normalizeUrlMap(value: unknown): JsonRecord { - const urls: JsonRecord = {}; - - const visit = (current: unknown, keyHint?: string): void => { - if (typeof current === "string") { - if (/^https?:\/\//i.test(current)) urls[keyHint || current] = current; - return; - } - - if (Array.isArray(current)) { - for (const item of current) visit(item, keyHint); - return; - } - - if (!isRecord(current)) return; - - const platform = pickString(current, ["platform", "source", "type", "name", "domain"]); - const url = pickString(current, ["url", "link", "href"]); - if (url && /^https?:\/\//i.test(url)) { - urls[platform || url] = url; - } - - for (const [key, child] of Object.entries(current)) { - visit(child, key); - } - }; - - visit(value); - return urls; -} - -export async function mapSongstatsResult( - endpoint: string, - query?: Record, - normalize?: (value: unknown) => unknown, -): Promise { - const result = await fetchSongstats(endpoint, query); - if (result.status !== 200 || !normalize) return result; - return { status: result.status, data: normalize(result.data) }; -} - -export function withoutLegacySearchParams(query?: Record): Record { - return { - q: query?.q || "", - ...(query?.limit ? { limit: query.limit } : {}), - ...(query?.offset ? { offset: query.offset } : {}), - }; -} diff --git a/lib/research/songstats/unsupportedResult.ts b/lib/research/songstats/unsupportedResult.ts new file mode 100644 index 000000000..80693c614 --- /dev/null +++ b/lib/research/songstats/unsupportedResult.ts @@ -0,0 +1,6 @@ +import type { ProxyResult } from "@/lib/research/ProxyResult"; + +export const UNSUPPORTED_RESULT: ProxyResult = { + status: 501, + data: { error: "Research data source does not support this endpoint" }, +}; diff --git a/lib/research/songstats/withoutLegacySearchParams.ts b/lib/research/songstats/withoutLegacySearchParams.ts new file mode 100644 index 000000000..3420c78e2 --- /dev/null +++ b/lib/research/songstats/withoutLegacySearchParams.ts @@ -0,0 +1,10 @@ +/** + * Strips legacy search params, keeping only `q` plus optional `limit`/`offset`. + */ +export function withoutLegacySearchParams(query?: Record): Record { + return { + q: query?.q || "", + ...(query?.limit ? { limit: query.limit } : {}), + ...(query?.offset ? { offset: query.offset } : {}), + }; +} diff --git a/lib/songstats/__tests__/fetchSongstats.test.ts b/lib/songstats/__tests__/fetchSongstats.test.ts index 8fe8bdca7..a23d57271 100644 --- a/lib/songstats/__tests__/fetchSongstats.test.ts +++ b/lib/songstats/__tests__/fetchSongstats.test.ts @@ -42,38 +42,15 @@ describe("fetchSongstats", () => { ); }); - it("uses the legacy SongStats_API env var when SONGSTATS_API_KEY is not configured", async () => { - delete process.env.SONGSTATS_API_KEY; - process.env.SongStats_API = "legacy-songstats-key"; - vi.mocked(fetch).mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ results: [] }), - headers: new Headers({ "content-type": "application/json" }), - } as Response); - - await fetchSongstats("/artists/search", { q: "Drake" }); - - expect(fetch).toHaveBeenCalledWith( - "https://api.songstats.com/enterprise/v1/artists/search?q=Drake", - expect.objectContaining({ - headers: expect.objectContaining({ - apikey: "legacy-songstats-key", - }), - }), - ); - }); - it("returns a sanitized 500-compatible result when no SongStats API key is configured", async () => { delete process.env.SONGSTATS_API_KEY; - delete process.env.SongStats_API; const result = await fetchSongstats("/artists/search", { q: "Drake" }); expect(result.status).toBe(500); expect(result.data).toEqual({ error: "Internal server error" }); expect(console.error).toHaveBeenCalledWith( - "[ERROR] fetchSongstats: SONGSTATS_API_KEY or SongStats_API environment variable is not set", + "[ERROR] fetchSongstats: SONGSTATS_API_KEY environment variable is not set", ); expect(fetch).not.toHaveBeenCalled(); }); diff --git a/lib/songstats/appendQueryParams.ts b/lib/songstats/appendQueryParams.ts new file mode 100644 index 000000000..dcff1476c --- /dev/null +++ b/lib/songstats/appendQueryParams.ts @@ -0,0 +1,12 @@ +/** + * Appends non-empty query params to a URL's search params in place. + */ +export function appendQueryParams(url: URL, queryParams?: Record): void { + if (!queryParams) return; + + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== "") { + url.searchParams.set(key, value); + } + } +} diff --git a/lib/songstats/fetchSongstats.ts b/lib/songstats/fetchSongstats.ts index adb2aa23c..7da19fd47 100644 --- a/lib/songstats/fetchSongstats.ts +++ b/lib/songstats/fetchSongstats.ts @@ -1,56 +1,10 @@ import { SONGSTATS_BASE } from "@/lib/songstats/songstatsBase"; import type { ProxyResult } from "@/lib/research/ProxyResult"; - -const DEFAULT_SONGSTATS_TIMEOUT_MS = 10_000; -// Slow SongStats stat calls run on routes with maxDuration=60; allow most of -// that budget upstream before aborting so they don't 504 prematurely. -const SLOW_SONGSTATS_TIMEOUT_MS = 50_000; -const SLOW_SONGSTATS_PATHS = new Set([ - "artists/historic_stats", - "artists/stats", - "artists/top_playlists", - "tracks/historic_stats", - "tracks/stats", -]); - -function appendQueryParams(url: URL, queryParams?: Record): void { - if (!queryParams) return; - - for (const [key, value] of Object.entries(queryParams)) { - if (value !== undefined && value !== "") { - url.searchParams.set(key, value); - } - } -} - -async function parseSongstatsResponse(response: Response): Promise { - const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("application/json")) return response.json(); - - const text = await response.text(); - return text ? { raw: text } : null; -} - -function getDefaultSongstatsTimeoutMs(path: string): number { - return SLOW_SONGSTATS_PATHS.has(path.replace(/^\/+/, "")) - ? SLOW_SONGSTATS_TIMEOUT_MS - : DEFAULT_SONGSTATS_TIMEOUT_MS; -} - -function getSongstatsTimeoutMs(path: string): number { - const configured = Number.parseInt(process.env.SONGSTATS_TIMEOUT_MS ?? "", 10); - return Number.isFinite(configured) && configured > 0 - ? configured - : getDefaultSongstatsTimeoutMs(path); -} - -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === "AbortError"; -} - -function getSongstatsApiKey(): string | undefined { - return process.env.SONGSTATS_API_KEY || process.env.SongStats_API; -} +import { appendQueryParams } from "@/lib/songstats/appendQueryParams"; +import { parseSongstatsResponse } from "@/lib/songstats/parseSongstatsResponse"; +import { getSongstatsTimeoutMs } from "@/lib/songstats/getSongstatsTimeoutMs"; +import { isAbortError } from "@/lib/songstats/isAbortError"; +import { getSongstatsApiKey } from "@/lib/songstats/getSongstatsApiKey"; export async function fetchSongstats( path: string, @@ -58,9 +12,7 @@ export async function fetchSongstats( ): Promise { const apiKey = getSongstatsApiKey(); if (!apiKey) { - console.error( - "[ERROR] fetchSongstats: SONGSTATS_API_KEY or SongStats_API environment variable is not set", - ); + console.error("[ERROR] fetchSongstats: SONGSTATS_API_KEY environment variable is not set"); return { data: { error: "Internal server error" }, status: 500, diff --git a/lib/songstats/getDefaultSongstatsTimeoutMs.ts b/lib/songstats/getDefaultSongstatsTimeoutMs.ts new file mode 100644 index 000000000..9a182afdb --- /dev/null +++ b/lib/songstats/getDefaultSongstatsTimeoutMs.ts @@ -0,0 +1,20 @@ +const DEFAULT_SONGSTATS_TIMEOUT_MS = 10_000; +// Slow SongStats stat calls run on routes with maxDuration=60; allow most of +// that budget upstream before aborting so they don't 504 prematurely. +const SLOW_SONGSTATS_TIMEOUT_MS = 50_000; +const SLOW_SONGSTATS_PATHS = new Set([ + "artists/historic_stats", + "artists/stats", + "artists/top_playlists", + "tracks/historic_stats", + "tracks/stats", +]); + +/** + * Returns the default SongStats timeout for a path, longer for known slow paths. + */ +export function getDefaultSongstatsTimeoutMs(path: string): number { + return SLOW_SONGSTATS_PATHS.has(path.replace(/^\/+/, "")) + ? SLOW_SONGSTATS_TIMEOUT_MS + : DEFAULT_SONGSTATS_TIMEOUT_MS; +} diff --git a/lib/songstats/getSongstatsApiKey.ts b/lib/songstats/getSongstatsApiKey.ts new file mode 100644 index 000000000..60b350399 --- /dev/null +++ b/lib/songstats/getSongstatsApiKey.ts @@ -0,0 +1,6 @@ +/** + * Returns the configured SongStats API key, if any. + */ +export function getSongstatsApiKey(): string | undefined { + return process.env.SONGSTATS_API_KEY; +} diff --git a/lib/songstats/getSongstatsTimeoutMs.ts b/lib/songstats/getSongstatsTimeoutMs.ts new file mode 100644 index 000000000..7e79df11a --- /dev/null +++ b/lib/songstats/getSongstatsTimeoutMs.ts @@ -0,0 +1,11 @@ +import { getDefaultSongstatsTimeoutMs } from "@/lib/songstats/getDefaultSongstatsTimeoutMs"; + +/** + * Resolves the SongStats request timeout from env, falling back to path defaults. + */ +export function getSongstatsTimeoutMs(path: string): number { + const configured = Number.parseInt(process.env.SONGSTATS_TIMEOUT_MS ?? "", 10); + return Number.isFinite(configured) && configured > 0 + ? configured + : getDefaultSongstatsTimeoutMs(path); +} diff --git a/lib/songstats/isAbortError.ts b/lib/songstats/isAbortError.ts new file mode 100644 index 000000000..5cb48a192 --- /dev/null +++ b/lib/songstats/isAbortError.ts @@ -0,0 +1,6 @@ +/** + * Returns true when the error is a fetch AbortError (request timeout). + */ +export function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} diff --git a/lib/songstats/parseSongstatsResponse.ts b/lib/songstats/parseSongstatsResponse.ts new file mode 100644 index 000000000..16673792e --- /dev/null +++ b/lib/songstats/parseSongstatsResponse.ts @@ -0,0 +1,10 @@ +/** + * Parses a SongStats response as JSON when possible, otherwise as raw text. + */ +export async function parseSongstatsResponse(response: Response): Promise { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) return response.json(); + + const text = await response.text(); + return text ? { raw: text } : null; +}