Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
supabase/.temp/
80 changes: 80 additions & 0 deletions lib/apify/spotify/__tests__/fetchSpotifyAlbumPlayCounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fetchSpotifyAlbumPlayCounts } from "../fetchSpotifyAlbumPlayCounts";

import apifyClient from "@/lib/apify/client";

vi.mock("@/lib/apify/client", () => {
return { default: { actor: vi.fn(), dataset: vi.fn() } };
});

const ITEMS = [
{
name: "K.I.D.S. (Deluxe)",
label: "Rostrum Records",
copyright: "℗ 2020 Rostrum Records",
tracks: [
{ id: "track_a", name: "The Spins", streamCount: 1331384578, duration: 181000 },
{ id: "track_b", name: "Nikes on My Feet", streamCount: 322000000, duration: 192000 },
],
},
];

describe("fetchSpotifyAlbumPlayCounts", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("calls the play-count actor with album URLs and returns parsed items + runId", async () => {
const call = vi
.fn()
.mockResolvedValue({ id: "run_1", defaultDatasetId: "ds_1", status: "SUCCEEDED" });
vi.mocked(apifyClient.actor).mockReturnValue({ call } as never);
const listItems = vi.fn().mockResolvedValue({ items: ITEMS });
vi.mocked(apifyClient.dataset).mockReturnValue({ listItems } as never);

const result = await fetchSpotifyAlbumPlayCounts(["5SKnXCvB4fcGSZu32o3LRY"]);

expect(apifyClient.actor).toHaveBeenCalledWith("beatanalytics~spotify-play-count-scraper");
expect(call).toHaveBeenCalledWith({
urls: [{ url: "https://open.spotify.com/album/5SKnXCvB4fcGSZu32o3LRY" }],
});
expect(apifyClient.dataset).toHaveBeenCalledWith("ds_1");
expect(result).toEqual({ runId: "run_1", albums: ITEMS });
});

it("deduplicates album ids before building actor urls", async () => {
const call = vi
.fn()
.mockResolvedValue({ id: "run_3", defaultDatasetId: "ds_3", status: "SUCCEEDED" });
vi.mocked(apifyClient.actor).mockReturnValue({ call } as never);
const listItems = vi.fn().mockResolvedValue({ items: ITEMS });
vi.mocked(apifyClient.dataset).mockReturnValue({ listItems } as never);

await fetchSpotifyAlbumPlayCounts(["album_a", "album_a", "album_b"]);

expect(call).toHaveBeenCalledWith({
urls: [
{ url: "https://open.spotify.com/album/album_a" },
{ url: "https://open.spotify.com/album/album_b" },
],
});
});

it("throws when the run fails", async () => {
const call = vi
.fn()
.mockResolvedValue({ id: "run_2", defaultDatasetId: "ds_2", status: "FAILED" });
vi.mocked(apifyClient.actor).mockReturnValue({ call } as never);

await expect(fetchSpotifyAlbumPlayCounts(["bad_album"])).rejects.toThrow(
"Spotify play-count actor run failed with status FAILED",
);
});

it("throws on empty input", async () => {
await expect(fetchSpotifyAlbumPlayCounts([])).rejects.toThrow(
"At least one Spotify album id is required",
);
expect(apifyClient.actor).not.toHaveBeenCalled();
});
});
47 changes: 47 additions & 0 deletions lib/apify/spotify/fetchSpotifyAlbumPlayCounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import apifyClient from "@/lib/apify/client";

const PLAY_COUNT_ACTOR = "beatanalytics~spotify-play-count-scraper";

export type SpotifyAlbumPlayCounts = {
name?: string;
label?: string;
copyright?: string;
tracks?: Array<{ id: string; name?: string; streamCount?: number; duration?: number }>;
};

export type FetchSpotifyAlbumPlayCountsResult = {
runId: string;
albums: SpotifyAlbumPlayCounts[];
};

/**
* Run the Spotify play-count actor for one or more albums and return the
* per-album track play counts. One album call captures all of its tracks;
* the actor reads platform-displayed counts (not royalty-bearing streams).
*
* @param spotifyAlbumIds - Spotify album ids to capture
* @returns The actor run id (for raw-payload lineage) and parsed album items
* @throws Error on empty input or a failed actor run
*/
export async function fetchSpotifyAlbumPlayCounts(
spotifyAlbumIds: string[],
): Promise<FetchSpotifyAlbumPlayCountsResult> {
if (spotifyAlbumIds.length === 0) {
throw new Error("At least one Spotify album id is required");
}

const uniqueAlbumIds = [...new Set(spotifyAlbumIds)];
const input = {
urls: uniqueAlbumIds.map(id => ({ url: `https://open.spotify.com/album/${id}` })),
};

const run = await apifyClient.actor(PLAY_COUNT_ACTOR).call(input);

if (!run?.id || !run?.defaultDatasetId || run.status !== "SUCCEEDED") {
throw new Error(`Spotify play-count actor run failed with status ${run?.status}`);
}

const { items } = await apifyClient.dataset(run.defaultDatasetId).listItems();

return { runId: run.id, albums: items as SpotifyAlbumPlayCounts[] };
}
28 changes: 28 additions & 0 deletions lib/research/__tests__/deductCredits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { deductCredits } from "../deductCredits";
import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction";

vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: vi.fn() }));

describe("deductCredits", () => {
beforeEach(() => vi.clearAllMocks());

it("deducts the research credit cost", async () => {
await deductCredits("acc_1");

expect(recordCreditDeduction).toHaveBeenCalledWith({
accountId: "acc_1",
creditsToDeduct: 5,
source: "api",
});
});

it("never throws when the deduction fails", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(recordCreditDeduction).mockRejectedValue(new Error("billing down"));

await expect(deductCredits("acc_1")).resolves.toBeUndefined();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
10 changes: 5 additions & 5 deletions lib/research/__tests__/getResearchTrackStatsHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { getResearchTrackStatsHandler } from "../getResearchTrackStatsHandler";
import { validateGetResearchTrackStatsRequest } from "../validateGetResearchTrackStatsRequest";
import { getResearchTrackStats } from "../getResearchTrackStats";
import { getTrackStatsApifyFirst } from "../playcounts/getTrackStatsApifyFirst";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));
vi.mock("../validateGetResearchTrackStatsRequest", () => ({
validateGetResearchTrackStatsRequest: vi.fn(),
}));
vi.mock("../getResearchTrackStats", () => ({ getResearchTrackStats: vi.fn() }));
vi.mock("../playcounts/getTrackStatsApifyFirst", () => ({ getTrackStatsApifyFirst: vi.fn() }));

const req = () =>
new NextRequest("http://x/api/research/track/stats?isrc=USQY51771120&source=spotify");
Expand All @@ -27,15 +27,15 @@ describe("getResearchTrackStatsHandler", () => {
);
const res = await getResearchTrackStatsHandler(req());
expect(res.status).toBe(400);
expect(getResearchTrackStats).not.toHaveBeenCalled();
expect(getTrackStatsApifyFirst).not.toHaveBeenCalled();
});

it("returns 200 with the Songstats stats envelope on success", async () => {
vi.mocked(validateGetResearchTrackStatsRequest).mockResolvedValue({
accountId: "acc_1",
params: { isrc: "USQY51771120", source: "spotify" },
});
vi.mocked(getResearchTrackStats).mockResolvedValue({
vi.mocked(getTrackStatsApifyFirst).mockResolvedValue({
data: {
result: "success",
message: "Data Retrieved.",
Expand All @@ -56,7 +56,7 @@ describe("getResearchTrackStatsHandler", () => {
accountId: "acc_1",
params: { isrc: "BADISRC", source: "spotify" },
});
vi.mocked(getResearchTrackStats).mockResolvedValue({
vi.mocked(getTrackStatsApifyFirst).mockResolvedValue({
error: "Request failed with status 404",
status: 404,
});
Expand Down
23 changes: 23 additions & 0 deletions lib/research/__tests__/labelSongstatsProvenance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { labelSongstatsProvenance } from "../labelSongstatsProvenance";

describe("labelSongstatsProvenance", () => {
it("labels every stats entry with data_source: songstats", () => {
const result = labelSongstatsProvenance({
result: "success",
stats: [
{ source: "spotify", data: { streams_total: 1 } },
{ source: "deezer", data: { streams_total: 2 } },
],
});

expect(result.stats).toEqual([
{ source: "spotify", data: { streams_total: 1 }, data_source: "songstats" },
{ source: "deezer", data: { streams_total: 2 }, data_source: "songstats" },
]);
});

it("passes envelope-only payloads through unchanged", () => {
expect(labelSongstatsProvenance({ result: "success" })).toEqual({ result: "success" });
});
});
22 changes: 22 additions & 0 deletions lib/research/deductCredits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction";

/** Credits charged per successful read-only research call. */
const RESEARCH_CREDIT_COST = 5;

/**
* Deduct research credits for a successful read. Failures are logged, never
* thrown — a billing hiccup must not fail a response we already have.
*
* @param accountId - The account to charge
*/
export async function deductCredits(accountId: string): Promise<void> {
try {
await recordCreditDeduction({
accountId,
creditsToDeduct: RESEARCH_CREDIT_COST,
source: "api",
});
} catch (error) {
console.error("[research] credit deduction failed:", error);
}
}
7 changes: 4 additions & 3 deletions lib/research/getResearchTrackStatsHandler.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { type NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/lib/networking/errorResponse";
import { successResponse } from "@/lib/networking/successResponse";
import { getResearchTrackStats } from "@/lib/research/getResearchTrackStats";
import { getTrackStatsApifyFirst } from "@/lib/research/playcounts/getTrackStatsApifyFirst";
import { validateGetResearchTrackStatsRequest } from "@/lib/research/validateGetResearchTrackStatsRequest";

/**
* GET /api/research/track/stats
*
* Per-track, per-source current stats (including absolute `streams_total`) by
* `isrc` / `songstats_track_id` / `spotify_track_id` / `apple_music_track_id`.
* Thin passthrough to Songstats; mirrors the `stats[].data` envelope of
* Apify-first for Spotify via the measurement store, Songstats for the
* remaining sources; mirrors the `stats[].data` envelope of
* `GET /api/research/metrics`, scoped to a single recording.
*
* @param request - The incoming HTTP request.
Expand All @@ -20,7 +21,7 @@ export async function getResearchTrackStatsHandler(request: NextRequest): Promis
const validated = await validateGetResearchTrackStatsRequest(request);
if (validated instanceof NextResponse) return validated;

const result = await getResearchTrackStats(validated);
const result = await getTrackStatsApifyFirst(validated);
if ("error" in result) return errorResponse(result.error, result.status);

const data = result.data;
Expand Down
18 changes: 18 additions & 0 deletions lib/research/labelSongstatsProvenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { TrackStatsPayload } from "@/lib/research/playcounts/trackStatsPayloadSchema";

/**
* Label every Songstats-sourced stat entry with its provenance
* (`data_source: "songstats"`), per the contract in docs#238. Pure typed
* transform — the payload is validated once at the orchestrator boundary
* ({@link trackStatsPayloadSchema}).
*
* @param payload - The parsed Songstats stats payload
* @returns The payload with labeled stat entries
*/
export function labelSongstatsProvenance(payload: TrackStatsPayload): TrackStatsPayload {
if (!payload.stats) return payload;
return {
...payload,
stats: payload.stats.map(entry => ({ ...entry, data_source: "songstats" })),
};
}
Loading
Loading