diff --git a/src/content/__tests__/selectAudioClip.test.ts b/src/content/__tests__/selectAudioClip.test.ts new file mode 100644 index 0000000..8ae3ad7 --- /dev/null +++ b/src/content/__tests__/selectAudioClip.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAudioClip } from "../selectAudioClip"; + +// Mock all external dependencies +vi.mock("@trigger.dev/sdk/v3", () => ({ + logger: { log: vi.fn() }, +})); + +vi.mock("../listArtistSongs", () => ({ + listArtistSongs: vi.fn(), + parseSongPath: vi.fn((path: string) => ({ repoUrl: null, filePath: path })), +})); + +vi.mock("../fetchGithubFile", () => ({ + fetchGithubFile: vi.fn(), +})); + +vi.mock("../transcribeSong", () => ({ + transcribeSong: vi.fn(), +})); + +vi.mock("../analyzeClips", () => ({ + analyzeClips: vi.fn(), +})); + +import { listArtistSongs } from "../listArtistSongs"; +import { fetchGithubFile } from "../fetchGithubFile"; +import { transcribeSong } from "../transcribeSong"; +import { analyzeClips } from "../analyzeClips"; + +const mockLyrics = { + fullLyrics: "test lyrics", + segments: [], +}; + +const mockClip = { + startSeconds: 10, + lyrics: "verse lyrics", + reason: "good hook", + mood: "upbeat", + hasLyrics: true, +}; + +const allSongPaths = [ + "artists/test-artist/songs/adhd/adhd.mp3", + "artists/test-artist/songs/hiccups/hiccups.mp3", + "artists/test-artist/songs/junk-drawer/junk-drawer.mp3", +]; + +describe("selectAudioClip", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchGithubFile).mockResolvedValue(Buffer.from("fake-mp3")); + vi.mocked(transcribeSong).mockResolvedValue(mockLyrics); + vi.mocked(analyzeClips).mockResolvedValue([mockClip]); + }); + + describe("song filtering", () => { + it("picks from all songs when songs is omitted", async () => { + vi.mocked(listArtistSongs).mockResolvedValue(allSongPaths); + + await selectAudioClip({ + githubRepo: "https://github.com/org/repo", + artistSlug: "test-artist", + clipDuration: 15, + lipsync: false, + }); + + // listArtistSongs was called — all 3 are eligible + expect(vi.mocked(listArtistSongs)).toHaveBeenCalledOnce(); + }); + + it("filters to specified songs when songs array is provided", async () => { + vi.mocked(listArtistSongs).mockResolvedValue(allSongPaths); + + await selectAudioClip({ + githubRepo: "https://github.com/org/repo", + artistSlug: "test-artist", + clipDuration: 15, + lipsync: false, + songs: ["adhd"], + }); + + // Only adhd.mp3 is eligible — transcribeSong should be called with adhd path + expect(vi.mocked(fetchGithubFile)).toHaveBeenCalledWith( + expect.any(String), + "artists/test-artist/songs/adhd/adhd.mp3", + ); + }); + + it("throws when none of the specified songs are found", async () => { + vi.mocked(listArtistSongs).mockResolvedValue(allSongPaths); + + await expect( + selectAudioClip({ + githubRepo: "https://github.com/org/repo", + artistSlug: "test-artist", + clipDuration: 15, + lipsync: false, + songs: ["nonexistent-song"], + }), + ).rejects.toThrow("None of the specified songs [nonexistent-song] were found"); + }); + + it("filters to multiple specified songs", async () => { + vi.mocked(listArtistSongs).mockResolvedValue(allSongPaths); + + // Should not throw — both adhd and hiccups exist + await expect( + selectAudioClip({ + githubRepo: "https://github.com/org/repo", + artistSlug: "test-artist", + clipDuration: 15, + lipsync: false, + songs: ["adhd", "hiccups"], + }), + ).resolves.toBeDefined(); + }); + }); +}); diff --git a/src/content/selectAudioClip.ts b/src/content/selectAudioClip.ts index 6f223d3..f302bb3 100644 --- a/src/content/selectAudioClip.ts +++ b/src/content/selectAudioClip.ts @@ -41,6 +41,7 @@ export interface SelectedAudioClip { * @param artistSlug - Artist directory name * @param clipDuration - How long the clip should be (seconds) * @param lipsync - Whether to prefer clips with lyrics (for lipsync mode) + * @param songs - Optional list of song slugs to restrict selection to * @returns Selected audio clip with all metadata */ export async function selectAudioClip({ @@ -48,18 +49,30 @@ export async function selectAudioClip({ artistSlug, clipDuration, lipsync, + songs, }: { githubRepo: string; artistSlug: string; clipDuration: number; lipsync: boolean; + songs?: string[]; }): Promise { // Step 1: List available songs - const songPaths = await listArtistSongs(githubRepo, artistSlug); + let songPaths = await listArtistSongs(githubRepo, artistSlug); if (songPaths.length === 0) { throw new Error(`No mp3 files found for artist ${artistSlug}`); } + // Filter to allowed songs if specified + if (songs && songs.length > 0) { + songPaths = songPaths.filter(path => + songs.some(slug => path.includes(`/songs/${slug}/`)), + ); + if (songPaths.length === 0) { + throw new Error(`None of the specified songs [${songs.join(", ")}] were found`); + } + } + // Step 2: Pick a random song const encodedPath = songPaths[Math.floor(Math.random() * songPaths.length)]; const { repoUrl, filePath: songPath } = parseSongPath(encodedPath); diff --git a/src/schemas/__tests__/contentCreationSchema.test.ts b/src/schemas/__tests__/contentCreationSchema.test.ts index 5ed44ca..c3c3fa0 100644 --- a/src/schemas/__tests__/contentCreationSchema.test.ts +++ b/src/schemas/__tests__/contentCreationSchema.test.ts @@ -38,5 +38,34 @@ describe("createContentPayloadSchema", () => { expect(result.success).toBe(false); }); + + it("accepts an optional songs array", () => { + const result = createContentPayloadSchema.safeParse({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + githubRepo: "https://github.com/recoupable/test-repo", + songs: ["adhd", "hiccups"], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.songs).toEqual(["adhd", "hiccups"]); + } + }); + + it("defaults songs to undefined when omitted", () => { + const result = createContentPayloadSchema.safeParse({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + githubRepo: "https://github.com/recoupable/test-repo", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.songs).toBeUndefined(); + } + }); }); diff --git a/src/schemas/contentCreationSchema.ts b/src/schemas/contentCreationSchema.ts index b733ca1..5e93d8f 100644 --- a/src/schemas/contentCreationSchema.ts +++ b/src/schemas/contentCreationSchema.ts @@ -14,6 +14,8 @@ export const createContentPayloadSchema = z.object({ upscale: z.boolean().default(false), /** GitHub repo URL so the task can fetch artist files (face-guide, songs). */ githubRepo: z.string().url("githubRepo must be a valid URL"), + /** Optional list of song slugs to pick from. When omitted, all songs are eligible. */ + songs: z.array(z.string()).optional(), }); export type CreateContentPayload = z.infer; diff --git a/src/tasks/createContentTask.ts b/src/tasks/createContentTask.ts index 5237ab4..01ccdc8 100644 --- a/src/tasks/createContentTask.ts +++ b/src/tasks/createContentTask.ts @@ -95,6 +95,7 @@ export const createContentTask = schemaTask({ artistSlug: payload.artistSlug, clipDuration: DEFAULT_PIPELINE_CONFIG.clipDuration, lipsync: payload.lipsync, + songs: payload.songs, }); // --- Step 4: Fetch artist/audience context ---