From 7ba4d983e41f405ff601d8a58156022d5c24c43e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 11 Jun 2026 12:26:28 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(research):=20self-mapping=20album=20cap?= =?UTF-8?q?tures=20=E2=80=94=20resolve=20ISRCs=20for=20unmapped=20tracks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes recoupable/chat#1794: writeAlbumPlayCounts silently skipped actor tracks with no song_identifiers row, and nothing ever created mappings — playcounts served 1/18 K.I.D.S. tracks and sibling stats fell back to Songstats. Now a capture self-maps as it goes: unmapped track ids batch-resolve to ISRCs via the Spotify API (GET /v1/tracks, 50/call, existing generateAccessToken creds), songs + track_id/album_id identifier rows are upserted, then measurements are written for every resolved track. Bootstrap failures degrade to mapped-only capture (logged) — the next snapshot retries. Shared path, so both the snapshot workflow and the on-demand stats refresh gain it. 12 new tests RED->GREEN; suite 623 files; tsc error-list diff vs test: 0 new; next build type phase passes. Co-Authored-By: Claude Fable 5 --- .../spotify/fetchSpotifyAlbumPlayCounts.ts | 1 + .../__tests__/mapUnmappedAlbumTracks.test.ts | 77 +++++++++++++++++++ .../__tests__/writeAlbumPlayCounts.test.ts | 17 ++++ .../playcounts/mapUnmappedAlbumTracks.ts | 73 ++++++++++++++++++ .../playcounts/writeAlbumPlayCounts.ts | 14 +++- lib/spotify/__tests__/getTracks.test.ts | 42 ++++++++++ lib/spotify/getTracks.ts | 55 +++++++++++++ .../__tests__/upsertSongIdentifiers.test.ts | 45 +++++++++++ .../song_identifiers/upsertSongIdentifiers.ts | 24 ++++++ 9 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 lib/research/playcounts/__tests__/mapUnmappedAlbumTracks.test.ts create mode 100644 lib/research/playcounts/mapUnmappedAlbumTracks.ts create mode 100644 lib/spotify/__tests__/getTracks.test.ts create mode 100644 lib/spotify/getTracks.ts create mode 100644 lib/supabase/song_identifiers/__tests__/upsertSongIdentifiers.test.ts create mode 100644 lib/supabase/song_identifiers/upsertSongIdentifiers.ts diff --git a/lib/apify/spotify/fetchSpotifyAlbumPlayCounts.ts b/lib/apify/spotify/fetchSpotifyAlbumPlayCounts.ts index fc09d6ace..6fcfeb16e 100644 --- a/lib/apify/spotify/fetchSpotifyAlbumPlayCounts.ts +++ b/lib/apify/spotify/fetchSpotifyAlbumPlayCounts.ts @@ -3,6 +3,7 @@ import apifyClient from "@/lib/apify/client"; const PLAY_COUNT_ACTOR = "beatanalytics~spotify-play-count-scraper"; export type SpotifyAlbumPlayCounts = { + id?: string; name?: string; label?: string; copyright?: string; diff --git a/lib/research/playcounts/__tests__/mapUnmappedAlbumTracks.test.ts b/lib/research/playcounts/__tests__/mapUnmappedAlbumTracks.test.ts new file mode 100644 index 000000000..b5e71facc --- /dev/null +++ b/lib/research/playcounts/__tests__/mapUnmappedAlbumTracks.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mapUnmappedAlbumTracks } from "../mapUnmappedAlbumTracks"; + +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getTracks from "@/lib/spotify/getTracks"; +import { upsertSongs } from "@/lib/supabase/songs/upsertSongs"; +import { upsertSongIdentifiers } from "@/lib/supabase/song_identifiers/upsertSongIdentifiers"; + +vi.mock("@/lib/spotify/generateAccessToken", () => ({ default: vi.fn() })); +vi.mock("@/lib/spotify/getTracks", () => ({ default: vi.fn() })); +vi.mock("@/lib/supabase/songs/upsertSongs", () => ({ upsertSongs: vi.fn() })); +vi.mock("@/lib/supabase/song_identifiers/upsertSongIdentifiers", () => ({ + upsertSongIdentifiers: vi.fn(), +})); + +const ALBUMS = [ + { + id: "album_1", + name: "K.I.D.S. (Deluxe)", + tracks: [ + { id: "t_mapped", name: "The Spins", streamCount: 1 }, + { id: "t_new", name: "Nikes on My Feet", streamCount: 2 }, + { id: "t_noisrc", name: "Interlude", streamCount: 3 }, + ], + }, +]; + +describe("mapUnmappedAlbumTracks", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(generateAccessToken).mockResolvedValue({ access_token: "tok" } as never); + vi.mocked(upsertSongs).mockResolvedValue([] as never); + }); + + it("resolves ISRCs for unmapped tracks, upserts songs + identifiers, returns new mappings", async () => { + vi.mocked(getTracks).mockResolvedValue({ + tracks: [ + { id: "t_new", name: "Nikes on My Feet", external_ids: { isrc: "ISRC_NIKES" } }, + { id: "t_noisrc", name: "Interlude", external_ids: {} }, + ], + error: null, + } as never); + + const mapped = await mapUnmappedAlbumTracks(ALBUMS, new Set(["t_mapped"])); + + expect(getTracks).toHaveBeenCalledWith({ ids: ["t_new", "t_noisrc"], accessToken: "tok" }); + expect(upsertSongs).toHaveBeenCalledWith([ + { isrc: "ISRC_NIKES", name: "Nikes on My Feet", album: "K.I.D.S. (Deluxe)" }, + ]); + expect(upsertSongIdentifiers).toHaveBeenCalledWith([ + { song: "ISRC_NIKES", platform: "spotify", identifier_type: "track_id", value: "t_new" }, + { song: "ISRC_NIKES", platform: "spotify", identifier_type: "album_id", value: "album_1" }, + ]); + expect([...mapped.entries()]).toEqual([["t_new", "ISRC_NIKES"]]); + }); + + it("returns an empty map without Spotify calls when everything is mapped", async () => { + const mapped = await mapUnmappedAlbumTracks(ALBUMS, new Set(["t_mapped", "t_new", "t_noisrc"])); + + expect(generateAccessToken).not.toHaveBeenCalled(); + expect(mapped.size).toBe(0); + }); + + it("degrades to an empty map (no throw) when Spotify auth fails — capture proceeds for already-mapped tracks", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(generateAccessToken).mockResolvedValue({ + access_token: null, + error: new Error("down"), + } as never); + + const mapped = await mapUnmappedAlbumTracks(ALBUMS, new Set()); + + expect(mapped.size).toBe(0); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/research/playcounts/__tests__/writeAlbumPlayCounts.test.ts b/lib/research/playcounts/__tests__/writeAlbumPlayCounts.test.ts index 9bb4fdc49..ae45bc7cd 100644 --- a/lib/research/playcounts/__tests__/writeAlbumPlayCounts.test.ts +++ b/lib/research/playcounts/__tests__/writeAlbumPlayCounts.test.ts @@ -3,6 +3,7 @@ import { writeAlbumPlayCounts } from "../writeAlbumPlayCounts"; import { selectSongIdentifiers } from "@/lib/supabase/song_identifiers/selectSongIdentifiers"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; +import { mapUnmappedAlbumTracks } from "../mapUnmappedAlbumTracks"; vi.mock("@/lib/supabase/song_identifiers/selectSongIdentifiers", () => ({ selectSongIdentifiers: vi.fn(), @@ -10,6 +11,7 @@ vi.mock("@/lib/supabase/song_identifiers/selectSongIdentifiers", () => ({ vi.mock("@/lib/supabase/song_measurements/upsertSongMeasurements", () => ({ upsertSongMeasurements: vi.fn(), })); +vi.mock("../mapUnmappedAlbumTracks", () => ({ mapUnmappedAlbumTracks: vi.fn() })); const ALBUMS = [ { @@ -27,6 +29,21 @@ describe("writeAlbumPlayCounts", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-11T12:00:00Z")); vi.mocked(upsertSongMeasurements).mockResolvedValue([] as never); + vi.mocked(mapUnmappedAlbumTracks).mockResolvedValue(new Map()); + }); + + it("self-maps unmapped tracks and writes their measurements too (chat#1794)", async () => { + vi.mocked(selectSongIdentifiers).mockResolvedValue([ + { song: "ISRC1", platform: "spotify", identifier_type: "track_id", value: "t1" }, + ]); + vi.mocked(mapUnmappedAlbumTracks).mockResolvedValue(new Map([["t_unmapped", "ISRC_NEW"]])); + + const written = await writeAlbumPlayCounts(ALBUMS, "run_3", {}); + + expect(mapUnmappedAlbumTracks).toHaveBeenCalledWith(ALBUMS, new Set(["t1"])); + const rows = vi.mocked(upsertSongMeasurements).mock.calls[0][0]; + expect(rows.map((r: { song: string }) => r.song).sort()).toEqual(["ISRC1", "ISRC_NEW"]); + expect(written).toBe(2); }); it("writes one measurement per mapped track with run + snapshot lineage", async () => { diff --git a/lib/research/playcounts/mapUnmappedAlbumTracks.ts b/lib/research/playcounts/mapUnmappedAlbumTracks.ts new file mode 100644 index 000000000..098627d72 --- /dev/null +++ b/lib/research/playcounts/mapUnmappedAlbumTracks.ts @@ -0,0 +1,73 @@ +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getTracks from "@/lib/spotify/getTracks"; +import { upsertSongs } from "@/lib/supabase/songs/upsertSongs"; +import { upsertSongIdentifiers } from "@/lib/supabase/song_identifiers/upsertSongIdentifiers"; +import { SpotifyAlbumPlayCounts } from "@/lib/apify/spotify/fetchSpotifyAlbumPlayCounts"; + +/** + * Self-mapping bootstrap (chat#1794): resolve ISRCs for actor tracks that have + * no identifier mapping yet — batch Spotify `/v1/tracks` lookup — and upsert + * the `songs` rows plus track_id/album_id mappings. Failures degrade to an + * empty map (logged): capture proceeds for already-mapped tracks and the next + * snapshot retries the rest. + * + * @param albums - Parsed actor album items (with album `id`) + * @param mappedTrackIds - Track ids that already have mappings + * @returns Newly created trackId → ISRC mappings + */ +export async function mapUnmappedAlbumTracks( + albums: SpotifyAlbumPlayCounts[], + mappedTrackIds: Set, +): Promise> { + const unmapped = albums.flatMap(album => + (album.tracks ?? []) + .filter(track => !mappedTrackIds.has(track.id)) + .map(track => ({ trackId: track.id, albumId: album.id, albumName: album.name })), + ); + if (unmapped.length === 0) return new Map(); + + try { + const { access_token, error: tokenError } = await generateAccessToken(); + if (!access_token) throw tokenError ?? new Error("No Spotify access token"); + + const { tracks, error } = await getTracks({ + ids: unmapped.map(u => u.trackId), + accessToken: access_token, + }); + if (!tracks) throw error ?? new Error("Spotify tracks lookup failed"); + + const contextByTrackId = new Map(unmapped.map(u => [u.trackId, u])); + const resolved = tracks.flatMap(track => { + const isrc = track.external_ids?.isrc; + const context = contextByTrackId.get(track.id); + if (!isrc || !context) return []; + return [ + { + trackId: track.id, + isrc, + name: track.name, + albumId: context.albumId, + albumName: context.albumName, + }, + ]; + }); + if (resolved.length === 0) return new Map(); + + await upsertSongs( + resolved.map(r => ({ isrc: r.isrc, name: r.name ?? null, album: r.albumName ?? null })), + ); + await upsertSongIdentifiers( + resolved.flatMap(r => [ + { song: r.isrc, platform: "spotify", identifier_type: "track_id", value: r.trackId }, + ...(r.albumId + ? [{ song: r.isrc, platform: "spotify", identifier_type: "album_id", value: r.albumId }] + : []), + ]), + ); + + return new Map(resolved.map(r => [r.trackId, r.isrc])); + } catch (error) { + console.error("[playcounts] identifier bootstrap failed:", error); + return new Map(); + } +} diff --git a/lib/research/playcounts/writeAlbumPlayCounts.ts b/lib/research/playcounts/writeAlbumPlayCounts.ts index ed31ae61c..65156a278 100644 --- a/lib/research/playcounts/writeAlbumPlayCounts.ts +++ b/lib/research/playcounts/writeAlbumPlayCounts.ts @@ -1,15 +1,19 @@ import { selectSongIdentifiers } from "@/lib/supabase/song_identifiers/selectSongIdentifiers"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; +import { mapUnmappedAlbumTracks } from "@/lib/research/playcounts/mapUnmappedAlbumTracks"; import { SpotifyAlbumPlayCounts } from "@/lib/apify/spotify/fetchSpotifyAlbumPlayCounts"; const METRIC = "platform_displayed_play_count"; const DATA_SOURCE = "apify_spotify_playcount"; /** - * Write actor album results into the measurement store: one row per track - * with an identifier mapping, stamped with the actor run id (and snapshot id - * when capturing for a snapshot job). Shared by the on-demand stats refresh - * and the snapshot workflow. + * Write actor album results into the measurement store, self-mapping as it + * goes (chat#1794): tracks without an identifier mapping get their ISRCs + * resolved via the Spotify API and their songs/identifier rows created, so a + * capture measures every track it touches. One measurement row per resolved + * track, stamped with the actor run id (and snapshot id when capturing for a + * snapshot job). Shared by the on-demand stats refresh and the snapshot + * workflow. * * @param albums - Parsed actor album items * @param runId - The actor run id (raw_ref lineage) @@ -28,6 +32,8 @@ export async function writeAlbumPlayCounts( values: tracks.map(t => t.id), }); const songByTrackId = new Map(mappings.map(m => [m.value, m.song])); + const newlyMapped = await mapUnmappedAlbumTracks(albums, new Set(songByTrackId.keys())); + for (const [trackId, isrc] of newlyMapped) songByTrackId.set(trackId, isrc); const capturedAt = new Date().toISOString(); const rows = tracks.flatMap(track => { diff --git a/lib/spotify/__tests__/getTracks.test.ts b/lib/spotify/__tests__/getTracks.test.ts new file mode 100644 index 000000000..32dfd06a0 --- /dev/null +++ b/lib/spotify/__tests__/getTracks.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import getTracks from "../getTracks"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as never; + +describe("getTracks", () => { + beforeEach(() => vi.clearAllMocks()); + + it("batch-fetches tracks (50 per call) and concatenates results", async () => { + const ids = Array.from({ length: 60 }, (_, i) => `t${i}`); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ tracks: [{ id: "t0", external_ids: { isrc: "ISRC0" } }] }), + } as never); + + const { tracks, error } = await getTracks({ ids, accessToken: "tok" }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][0]).toContain( + "ids=" + encodeURIComponent(ids.slice(0, 50).join(",")), + ); + expect(error).toBeNull(); + expect(tracks).toHaveLength(2); + }); + + it("returns an error on a failed response", async () => { + mockFetch.mockResolvedValue({ ok: false, status: 429 } as never); + + const { tracks, error } = await getTracks({ ids: ["t1"], accessToken: "tok" }); + + expect(tracks).toBeNull(); + expect(error).toBeInstanceOf(Error); + }); + + it("returns [] for empty input without fetching", async () => { + const { tracks } = await getTracks({ ids: [], accessToken: "tok" }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(tracks).toEqual([]); + }); +}); diff --git a/lib/spotify/getTracks.ts b/lib/spotify/getTracks.ts new file mode 100644 index 000000000..4f829744a --- /dev/null +++ b/lib/spotify/getTracks.ts @@ -0,0 +1,55 @@ +import { SpotifyTrack } from "@/types/spotify.types"; + +const BATCH_SIZE = 50; + +/** + * Fetch multiple tracks by id via `GET /v1/tracks` (the only public surface + * that returns `external_ids.isrc`), batched at the API's 50-id limit. + * + * @param params.ids - Spotify track ids + * @param params.accessToken - Client-credentials access token + * @returns All fetched tracks, or an error + */ +const getTracks = async ({ + ids, + accessToken, +}: { + ids: string[]; + accessToken: string; +}): Promise<{ tracks: SpotifyTrack[] | null; error: Error | null }> => { + if (ids.length === 0) return { tracks: [], error: null }; + + try { + const tracks: SpotifyTrack[] = []; + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const batch = ids.slice(i, i + BATCH_SIZE); + const url = `https://api.spotify.com/v1/tracks?ids=${encodeURIComponent(batch.join(","))}`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + return { + tracks: null, + error: new Error(`Spotify tracks request failed: ${response.status}`), + }; + } + + const data = await response.json(); + tracks.push(...(data.tracks ?? []).filter(Boolean)); + } + return { tracks, error: null }; + } catch (error) { + console.error(error); + return { + tracks: null, + error: error instanceof Error ? error : new Error("Unknown error fetching tracks"), + }; + } +}; + +export default getTracks; diff --git a/lib/supabase/song_identifiers/__tests__/upsertSongIdentifiers.test.ts b/lib/supabase/song_identifiers/__tests__/upsertSongIdentifiers.test.ts new file mode 100644 index 000000000..037144ef3 --- /dev/null +++ b/lib/supabase/song_identifiers/__tests__/upsertSongIdentifiers.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { upsertSongIdentifiers } from "../upsertSongIdentifiers"; + +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { default: { from: mockFrom } }; +}); + +const ROWS = [ + { song: "ISRC1", platform: "spotify", identifier_type: "track_id", value: "t1" }, + { song: "ISRC1", platform: "spotify", identifier_type: "album_id", value: "a1" }, +]; + +describe("upsertSongIdentifiers", () => { + beforeEach(() => vi.clearAllMocks()); + + it("upserts mappings, ignoring existing (an external id maps once)", async () => { + const upsert = vi.fn().mockResolvedValue({ error: null }); + vi.mocked(supabase.from).mockReturnValue({ upsert } as never); + + await upsertSongIdentifiers(ROWS); + + expect(supabase.from).toHaveBeenCalledWith("song_identifiers"); + expect(upsert).toHaveBeenCalledWith(ROWS, { + onConflict: "platform,identifier_type,value", + ignoreDuplicates: true, + }); + }); + + it("skips the query for empty input", async () => { + await upsertSongIdentifiers([]); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("throws on error (mappings are load-bearing)", async () => { + const upsert = vi.fn().mockResolvedValue({ error: { message: "boom" } }); + vi.mocked(supabase.from).mockReturnValue({ upsert } as never); + + await expect(upsertSongIdentifiers(ROWS)).rejects.toThrow( + "Failed to upsert song identifiers: boom", + ); + }); +}); diff --git a/lib/supabase/song_identifiers/upsertSongIdentifiers.ts b/lib/supabase/song_identifiers/upsertSongIdentifiers.ts new file mode 100644 index 000000000..6cfb8baa0 --- /dev/null +++ b/lib/supabase/song_identifiers/upsertSongIdentifiers.ts @@ -0,0 +1,24 @@ +import supabase from "../serverClient"; +import { TablesInsert } from "@/types/database.types"; + +/** + * Upsert identifier mappings, ignoring rows whose (platform, identifier_type, + * value) already exists — an external identifier maps to exactly one song. + * + * @param rows - Mapping rows to upsert + * @throws Error if the upsert fails (mappings are load-bearing for capture) + */ +export async function upsertSongIdentifiers( + rows: TablesInsert<"song_identifiers">[], +): Promise { + if (rows.length === 0) return; + + const { error } = await supabase.from("song_identifiers").upsert(rows, { + onConflict: "platform,identifier_type,value", + ignoreDuplicates: true, + }); + + if (error) { + throw new Error(`Failed to upsert song identifiers: ${error.message}`); + } +} From 0a0d898c949d5b05283fdd2aac7786e73df5bfec Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 11 Jun 2026 12:40:40 -0500 Subject: [PATCH 2/2] fix(research): album mappings for every captured track + per-song onConflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with recoupable/database#34 (uniqueness moves to song,platform, identifier_type,value): albums map to many songs, so writeAlbumPlayCounts now upserts an album_id row for every captured track each capture (idempotent) — healing tracks that were track-mapped before their album row could exist. Co-Authored-By: Claude Fable 5 --- .../__tests__/writeAlbumPlayCounts.test.ts | 17 +++++++++++++++++ lib/research/playcounts/writeAlbumPlayCounts.ts | 15 +++++++++++++++ .../__tests__/upsertSongIdentifiers.test.ts | 4 ++-- .../song_identifiers/upsertSongIdentifiers.ts | 4 ++-- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/research/playcounts/__tests__/writeAlbumPlayCounts.test.ts b/lib/research/playcounts/__tests__/writeAlbumPlayCounts.test.ts index ae45bc7cd..14ebbe10c 100644 --- a/lib/research/playcounts/__tests__/writeAlbumPlayCounts.test.ts +++ b/lib/research/playcounts/__tests__/writeAlbumPlayCounts.test.ts @@ -4,6 +4,7 @@ import { writeAlbumPlayCounts } from "../writeAlbumPlayCounts"; import { selectSongIdentifiers } from "@/lib/supabase/song_identifiers/selectSongIdentifiers"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; import { mapUnmappedAlbumTracks } from "../mapUnmappedAlbumTracks"; +import { upsertSongIdentifiers } from "@/lib/supabase/song_identifiers/upsertSongIdentifiers"; vi.mock("@/lib/supabase/song_identifiers/selectSongIdentifiers", () => ({ selectSongIdentifiers: vi.fn(), @@ -12,9 +13,13 @@ vi.mock("@/lib/supabase/song_measurements/upsertSongMeasurements", () => ({ upsertSongMeasurements: vi.fn(), })); vi.mock("../mapUnmappedAlbumTracks", () => ({ mapUnmappedAlbumTracks: vi.fn() })); +vi.mock("@/lib/supabase/song_identifiers/upsertSongIdentifiers", () => ({ + upsertSongIdentifiers: vi.fn(), +})); const ALBUMS = [ { + id: "album_1", name: "K.I.D.S. (Deluxe)", tracks: [ { id: "t1", name: "The Spins", streamCount: 100 }, @@ -68,6 +73,18 @@ describe("writeAlbumPlayCounts", () => { expect(written).toBe(1); }); + it("upserts album_id mappings for every captured track (heals pre-mapped tracks, chat#1794)", async () => { + vi.mocked(selectSongIdentifiers).mockResolvedValue([ + { song: "ISRC1", platform: "spotify", identifier_type: "track_id", value: "t1" }, + ]); + + await writeAlbumPlayCounts(ALBUMS, "run_4", {}); + + expect(upsertSongIdentifiers).toHaveBeenCalledWith([ + { song: "ISRC1", platform: "spotify", identifier_type: "album_id", value: "album_1" }, + ]); + }); + it("omits snapshot lineage when not given", async () => { vi.mocked(selectSongIdentifiers).mockResolvedValue([ { song: "ISRC1", platform: "spotify", identifier_type: "track_id", value: "t1" }, diff --git a/lib/research/playcounts/writeAlbumPlayCounts.ts b/lib/research/playcounts/writeAlbumPlayCounts.ts index 65156a278..2e33c0f3b 100644 --- a/lib/research/playcounts/writeAlbumPlayCounts.ts +++ b/lib/research/playcounts/writeAlbumPlayCounts.ts @@ -1,6 +1,7 @@ import { selectSongIdentifiers } from "@/lib/supabase/song_identifiers/selectSongIdentifiers"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; import { mapUnmappedAlbumTracks } from "@/lib/research/playcounts/mapUnmappedAlbumTracks"; +import { upsertSongIdentifiers } from "@/lib/supabase/song_identifiers/upsertSongIdentifiers"; import { SpotifyAlbumPlayCounts } from "@/lib/apify/spotify/fetchSpotifyAlbumPlayCounts"; const METRIC = "platform_displayed_play_count"; @@ -34,6 +35,20 @@ export async function writeAlbumPlayCounts( const songByTrackId = new Map(mappings.map(m => [m.value, m.song])); const newlyMapped = await mapUnmappedAlbumTracks(albums, new Set(songByTrackId.keys())); for (const [trackId, isrc] of newlyMapped) songByTrackId.set(trackId, isrc); + + // Album mappings for every captured track (idempotent): albums map to many + // songs, and pre-mapped tracks would otherwise never get their album row. + const albumRows = albums.flatMap(album => + !album.id + ? [] + : (album.tracks ?? []).flatMap(track => { + const song = songByTrackId.get(track.id); + if (!song) return []; + return [{ song, platform: "spotify", identifier_type: "album_id", value: album.id }]; + }), + ); + await upsertSongIdentifiers(albumRows); + const capturedAt = new Date().toISOString(); const rows = tracks.flatMap(track => { diff --git a/lib/supabase/song_identifiers/__tests__/upsertSongIdentifiers.test.ts b/lib/supabase/song_identifiers/__tests__/upsertSongIdentifiers.test.ts index 037144ef3..367fc73a1 100644 --- a/lib/supabase/song_identifiers/__tests__/upsertSongIdentifiers.test.ts +++ b/lib/supabase/song_identifiers/__tests__/upsertSongIdentifiers.test.ts @@ -16,7 +16,7 @@ const ROWS = [ describe("upsertSongIdentifiers", () => { beforeEach(() => vi.clearAllMocks()); - it("upserts mappings, ignoring existing (an external id maps once)", async () => { + it("upserts mappings, ignoring existing (one mapping per song per identifier)", async () => { const upsert = vi.fn().mockResolvedValue({ error: null }); vi.mocked(supabase.from).mockReturnValue({ upsert } as never); @@ -24,7 +24,7 @@ describe("upsertSongIdentifiers", () => { expect(supabase.from).toHaveBeenCalledWith("song_identifiers"); expect(upsert).toHaveBeenCalledWith(ROWS, { - onConflict: "platform,identifier_type,value", + onConflict: "song,platform,identifier_type,value", ignoreDuplicates: true, }); }); diff --git a/lib/supabase/song_identifiers/upsertSongIdentifiers.ts b/lib/supabase/song_identifiers/upsertSongIdentifiers.ts index 6cfb8baa0..ca5c55f91 100644 --- a/lib/supabase/song_identifiers/upsertSongIdentifiers.ts +++ b/lib/supabase/song_identifiers/upsertSongIdentifiers.ts @@ -3,7 +3,7 @@ import { TablesInsert } from "@/types/database.types"; /** * Upsert identifier mappings, ignoring rows whose (platform, identifier_type, - * value) already exists — an external identifier maps to exactly one song. + * value) already exists — one mapping per (song, platform, identifier_type, value). * * @param rows - Mapping rows to upsert * @throws Error if the upsert fails (mappings are load-bearing for capture) @@ -14,7 +14,7 @@ export async function upsertSongIdentifiers( if (rows.length === 0) return; const { error } = await supabase.from("song_identifiers").upsert(rows, { - onConflict: "platform,identifier_type,value", + onConflict: "song,platform,identifier_type,value", ignoreDuplicates: true, });