Skip to content
Open
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
120 changes: 120 additions & 0 deletions src/content/__tests__/selectAudioClip.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Comment on lines +58 to +119
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a regression test for songs: [] semantics.

Given Line 67 in src/content/selectAudioClip.ts, an explicit empty array currently behaves like “no filter.” Please add a test that locks the intended behavior for songs: [] so this edge case doesn’t regress.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/__tests__/selectAudioClip.test.ts` around lines 58 - 119, Add a
test to the "song filtering" suite verifying that passing songs: [] to
selectAudioClip is treated the same as omitting the songs field (i.e., no
filter): mock listArtistSongs to return allSongPaths, call selectAudioClip with
songs: [], then assert behavior consistent with the first test (e.g.,
vi.mocked(listArtistSongs) was called and selectAudioClip resolves and/or
fetchGithubFile is called for any eligible song); use the existing test helpers
and the same structure as the other tests so this edge case is covered.

});
15 changes: 14 additions & 1 deletion src/content/selectAudioClip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,38 @@ 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({
githubRepo,
artistSlug,
clipDuration,
lipsync,
songs,
}: {
githubRepo: string;
artistSlug: string;
clipDuration: number;
lipsync: boolean;
songs?: string[];
}): Promise<SelectedAudioClip> {
// 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`);
}
}
Comment on lines +67 to +74
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Honor explicit empty songs input instead of falling back to all songs.

Line 67 currently skips filtering when songs is [], so an explicit filter can unintentionally select any song. That breaks the payload contract where provided songs should constrain eligibility.

💡 Suggested fix
-  if (songs && songs.length > 0) {
-    songPaths = songPaths.filter(path =>
-      songs.some(slug => path.includes(`/songs/${slug}/`)),
-    );
+  if (songs !== undefined) {
+    const requestedSlugs = songs.map(slug => slug.trim()).filter(Boolean);
+    if (requestedSlugs.length === 0) {
+      throw new Error("songs must include at least one non-empty slug when provided");
+    }
+
+    songPaths = songPaths.filter(path =>
+      requestedSlugs.some(slug => path.includes(`/songs/${slug}/`)),
+    );
     if (songPaths.length === 0) {
-      throw new Error(`None of the specified songs [${songs.join(", ")}] were found`);
+      throw new Error(`None of the specified songs [${requestedSlugs.join(", ")}] were found`);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/content/selectAudioClip.ts` around lines 67 - 74, The code currently only
applies the songs filter when songs.length > 0, which causes an explicit empty
songs array to be ignored; change the condition to detect the presence of the
songs parameter (e.g., if (songs !== undefined && songs !== null) or if
(Array.isArray(songs))) so that songPaths is always filtered against the
provided songs array (even when empty), and keep the existing throw when
songPaths.length === 0; update the block around the songPaths filtering in
selectAudioClip (variables: songs, songPaths) accordingly.


// Step 2: Pick a random song
const encodedPath = songPaths[Math.floor(Math.random() * songPaths.length)];
const { repoUrl, filePath: songPath } = parseSongPath(encodedPath);
Expand Down
29 changes: 29 additions & 0 deletions src/schemas/__tests__/contentCreationSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});

2 changes: 2 additions & 0 deletions src/schemas/contentCreationSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createContentPayloadSchema>;
Expand Down
1 change: 1 addition & 0 deletions src/tasks/createContentTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
Loading