Skip to content
103 changes: 103 additions & 0 deletions src/content/__tests__/filterSongPaths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect, vi } from "vitest";
import { filterSongPaths } from "../filterSongPaths";

vi.mock("../../sandboxes/logStep", () => ({
logStep: vi.fn(),
}));

describe("filterSongPaths", () => {
const artistSlug = "gatsby-grace";

it("keeps only paths matching the requested slugs", () => {
const songPaths = [
"artists/gatsby-grace/songs/hiccups/hiccups.mp3",
"artists/gatsby-grace/songs/adhd/adhd.mp3",
"artists/gatsby-grace/songs/freefall/freefall.mp3",
];

const result = filterSongPaths(songPaths, ["hiccups", "adhd"], artistSlug);

expect(result).toEqual([
"artists/gatsby-grace/songs/hiccups/hiccups.mp3",
"artists/gatsby-grace/songs/adhd/adhd.mp3",
]);
});

it("uses exact matching — 'ad' does not match 'adhd'", () => {
const songPaths = [
"artists/gatsby-grace/songs/adhd/adhd.mp3",
"artists/gatsby-grace/songs/ad/ad.mp3",
];

const result = filterSongPaths(songPaths, ["ad"], artistSlug);

expect(result).toEqual([
"artists/gatsby-grace/songs/ad/ad.mp3",
]);
});

it("throws when no songs match", () => {
const songPaths = [
"artists/gatsby-grace/songs/hiccups/hiccups.mp3",
];

expect(() => filterSongPaths(songPaths, ["nonexistent"], artistSlug)).toThrow(
"None of the specified songs [nonexistent] were found for artist gatsby-grace",
);
});

it("handles case-insensitive slug matching", () => {
const songPaths = [
"artists/gatsby-grace/songs/Hiccups/Hiccups.mp3",
];

const result = filterSongPaths(songPaths, ["hiccups"], artistSlug);

expect(result).toEqual([
"artists/gatsby-grace/songs/Hiccups/Hiccups.mp3",
]);
});

it("trims whitespace from slugs", () => {
const songPaths = [
"artists/gatsby-grace/songs/hiccups/hiccups.mp3",
];

const result = filterSongPaths(songPaths, [" hiccups "], artistSlug);

expect(result).toEqual([
"artists/gatsby-grace/songs/hiccups/hiccups.mp3",
]);
});

it("returns all paths when songs is undefined", () => {
const songPaths = [
"artists/gatsby-grace/songs/hiccups/hiccups.mp3",
"artists/gatsby-grace/songs/adhd/adhd.mp3",
];

const result = filterSongPaths(songPaths, undefined, artistSlug);

expect(result).toEqual(songPaths);
});

it("returns all paths when songs is empty", () => {
const songPaths = [
"artists/gatsby-grace/songs/hiccups/hiccups.mp3",
];

const result = filterSongPaths(songPaths, [], artistSlug);

expect(result).toEqual(songPaths);
});

it("works with org repo encoded paths", () => {
const songPaths = [
"__ORG_REPO__https://github.com/org/repo__artists/gatsby-grace/songs/hiccups/hiccups.mp3",
];

const result = filterSongPaths(songPaths, ["hiccups"], artistSlug);

expect(result).toEqual([songPaths[0]]);
});
});
37 changes: 37 additions & 0 deletions src/content/filterSongPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { parseSongPath } from "./listArtistSongs";
import { logStep } from "../sandboxes/logStep";

/**
* Filters song paths to only include those matching the given slugs.
* Uses exact slug matching against the directory name under /songs/.
* Returns songPaths unmodified when songs is empty or undefined.
*
* @param songPaths - Encoded song paths from listArtistSongs
* @param songs - Song slugs to keep (e.g. ["hiccups", "adhd"])
* @param artistSlug - Artist slug (for error messages)
* @returns Filtered song paths
*/
export function filterSongPaths(
songPaths: string[],
songs: string[] | undefined,
artistSlug: string,
): string[] {
if (!songs || songs.length === 0) return songPaths;

const requested = new Set(songs.map(s => s.trim().toLowerCase()).filter(Boolean));

const filtered = songPaths.filter(encodedPath => {
const { filePath } = parseSongPath(encodedPath);
const match = filePath.match(/\/songs\/([^/]+)\//);
return !!match && requested.has(match[1].toLowerCase());
});

if (filtered.length === 0) {
throw new Error(
`None of the specified songs [${songs.join(", ")}] were found for artist ${artistSlug}`,
);
}

logStep("Filtered to specified songs", false, { songs, matchCount: filtered.length });
return filtered;
}
24 changes: 12 additions & 12 deletions src/content/selectAudioClip.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { logger } from "@trigger.dev/sdk/v3";
import { listArtistSongs, parseSongPath } from "./listArtistSongs";
import { filterSongPaths } from "./filterSongPaths";
import { fetchGithubFile } from "./fetchGithubFile";
import { transcribeSong } from "./transcribeSong";
import { analyzeClips, type SongClip } from "./analyzeClips";
import type { SongLyrics } from "./transcribeSong";
import type { CreateContentPayload } from "../schemas/contentCreationSchema";
import { DEFAULT_PIPELINE_CONFIG } from "./defaultPipelineConfig";

export interface SelectedAudioClip {
/** Original song filename */
Expand Down Expand Up @@ -43,23 +46,20 @@ export interface SelectedAudioClip {
* @param lipsync - Whether to prefer clips with lyrics (for lipsync mode)
* @returns Selected audio clip with all metadata
*/
export async function selectAudioClip({
githubRepo,
artistSlug,
clipDuration,
lipsync,
}: {
githubRepo: string;
artistSlug: string;
clipDuration: number;
lipsync: boolean;
}): Promise<SelectedAudioClip> {
export async function selectAudioClip(
payload: Pick<CreateContentPayload, "githubRepo" | "artistSlug" | "lipsync" | "songs">,
): Promise<SelectedAudioClip> {
const { githubRepo, artistSlug, lipsync, songs } = payload;
const clipDuration = DEFAULT_PIPELINE_CONFIG.clipDuration;
// 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}`);
}

// Step 1b: Filter to allowed songs if specified
songPaths = filterSongPaths(songPaths, songs, artistSlug);

// Step 2: Pick a random song
const encodedPath = songPaths[Math.floor(Math.random() * songPaths.length)];
const { repoUrl, filePath: songPath } = parseSongPath(encodedPath);
Expand Down
3 changes: 0 additions & 3 deletions src/content/testPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,12 @@ async function testUpscaleVideo(): Promise<void> {
async function testAudio(): Promise<void> {
setupFal();
const { selectAudioClip } = await import("./selectAudioClip.js");
const { DEFAULT_PIPELINE_CONFIG } = await import("./defaultPipelineConfig.js");

console.log("\n🎵 Testing: Audio Selection\n");
console.log(" 🔄 Finding songs, transcribing, analyzing...\n");

const clip = await selectAudioClip({
githubRepo: GITHUB_REPO,
artistSlug: ARTIST_SLUG,
clipDuration: DEFAULT_PIPELINE_CONFIG.clipDuration,
lipsync: false,
});

Expand Down
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 restrict which songs the pipeline picks from. */
songs: z.array(z.string()).optional(),
Comment on lines +17 to +18
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

Harden songs validation to reject empty/invalid slugs.

Right now songs accepts [""] (or whitespace), which can lead to unintended filtering behavior downstream. Validate each slug and reject empty entries at the schema boundary.

🔧 Proposed schema tightening
+const songSlugSchema = z
+  .string()
+  .trim()
+  .min(1, "song slug cannot be empty")
+  .regex(/^[a-z0-9-]+$/i, "song slug must contain only letters, numbers, and hyphens");
+
 export const createContentPayloadSchema = z.object({
   accountId: z.string().min(1, "accountId is required"),
   artistSlug: z.string().min(1, "artistSlug is required"),
   template: z.string().min(1, "template is required"),
   lipsync: 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 restrict which songs the pipeline picks from. */
-  songs: z.array(z.string()).optional(),
+  songs: z.array(songSlugSchema).optional(),
 });
As per coding guidelines "src/**/*.{ts,tsx}: Use Zod for schema validation".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/schemas/contentCreationSchema.ts` around lines 17 - 18, The songs array
currently allows empty or whitespace-only strings; update the
contentCreationSchema's songs definition so each element is validated as a
non-empty, trimmed slug and invalid values are rejected at the schema boundary.
Replace the element schema used in songs with a zod string that trims input,
enforces non-empty (e.g., .nonempty() or .refine(s => s.length > 0)), and
validates against a slug pattern (e.g., alphanumerics, dashes/underscores) using
a regex; keep the outer songs as optionally present (songs:
z.array(...).optional()). Ensure the symbol names to change are songs in
contentCreationSchema and use Zod methods (transform/trim, nonempty/refine,
regex) to implement this tightening.

});

export type CreateContentPayload = z.infer<typeof createContentPayloadSchema>;
Expand Down
8 changes: 1 addition & 7 deletions src/tasks/createContentTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { generateCaption } from "../content/generateCaption";
import { fetchArtistContext } from "../content/fetchArtistContext";
import { fetchAudienceContext } from "../content/fetchAudienceContext";
import { renderFinalVideo } from "../content/renderFinalVideo";
import { DEFAULT_PIPELINE_CONFIG } from "../content/defaultPipelineConfig";
import {
loadTemplate,
pickRandomReferenceImage,
Expand Down Expand Up @@ -90,12 +89,7 @@ export const createContentTask = schemaTask({

// --- Step 3: Select audio clip ---
logStep("Selecting audio clip");
const audioClip = await selectAudioClip({
githubRepo: payload.githubRepo,
artistSlug: payload.artistSlug,
clipDuration: DEFAULT_PIPELINE_CONFIG.clipDuration,
lipsync: payload.lipsync,
});
const audioClip = await selectAudioClip(payload);

// --- Step 4: Fetch artist/audience context ---
logStep("Fetching artist context");
Expand Down
Loading