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
155 changes: 155 additions & 0 deletions __tests__/commands/songs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

vi.mock("../../src/client.js", () => ({
get: vi.fn(),
post: vi.fn(),
}));

import { songsCommand } from "../../src/commands/songs.js";
import { get, post } from "../../src/client.js";

let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("songs analyze", () => {
it("sends custom prompt to /api/songs/analyze", async () => {
vi.mocked(post).mockResolvedValue({
status: "success",
response: "This is jazz music.",
elapsed_seconds: 3.2,
});

await songsCommand.parseAsync(["analyze", "--prompt", "What genre is this?"], { from: "user" });

expect(post).toHaveBeenCalledWith("/api/songs/analyze", {
prompt: "What genre is this?",
});
});

it("sends preset to /api/songs/analyze", async () => {
vi.mocked(post).mockResolvedValue({
status: "success",
preset: "catalog_metadata",
response: { genre: "pop", tempo_bpm: 120 },
elapsed_seconds: 8.0,
});

await songsCommand.parseAsync(["analyze", "--preset", "catalog_metadata", "--audio", "https://example.com/song.mp3"], { from: "user" });

expect(post).toHaveBeenCalledWith("/api/songs/analyze", {
preset: "catalog_metadata",
audio_url: "https://example.com/song.mp3",
});
});

it("prints text response plainly", async () => {
vi.mocked(post).mockResolvedValue({
status: "success",
response: "This is a jazz piece in Bb major.",
elapsed_seconds: 2.5,
});

await songsCommand.parseAsync(["analyze", "--prompt", "Describe this track."], { from: "user" });

expect(logSpy).toHaveBeenCalledWith("This is a jazz piece in Bb major.");
});

it("prints JSON preset response as formatted JSON", async () => {
vi.mocked(post).mockResolvedValue({
status: "success",
response: { genre: "pop", tempo_bpm: 96 },
elapsed_seconds: 10.0,
});

await songsCommand.parseAsync(["analyze", "--preset", "catalog_metadata", "--audio", "https://example.com/song.mp3"], { from: "user" });

expect(logSpy).toHaveBeenCalledWith(
JSON.stringify({ genre: "pop", tempo_bpm: 96 }, null, 2),
);
});

it("prints full JSON with --json flag", async () => {
const data = {
status: "success",
response: "Jazz in Bb major.",
elapsed_seconds: 3.0,
};
vi.mocked(post).mockResolvedValue(data);

await songsCommand.parseAsync(["analyze", "--prompt", "Describe this.", "--json"], { from: "user" });

expect(logSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2));
});

it("includes audio_url when --audio is provided", async () => {
vi.mocked(post).mockResolvedValue({
status: "success",
response: "Upbeat pop.",
elapsed_seconds: 10.0,
});

await songsCommand.parseAsync(["analyze", "--prompt", "Describe this.", "--audio", "https://example.com/song.mp3"], { from: "user" });

expect(post).toHaveBeenCalledWith("/api/songs/analyze", {
prompt: "Describe this.",
audio_url: "https://example.com/song.mp3",
});
});

it("prints error on failure", async () => {
vi.mocked(post).mockRejectedValue(new Error("Service Unavailable"));

await songsCommand.parseAsync(["analyze", "--prompt", "Describe this."], { from: "user" });

expect(errorSpy).toHaveBeenCalledWith("Error: Service Unavailable");
expect(exitSpy).toHaveBeenCalledWith(1);
});

it("exits with error when no prompt or preset given", async () => {
await songsCommand.parseAsync(["analyze"], { from: "user" });

expect(errorSpy).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

describe("songs presets", () => {
it("lists presets from /api/songs/analyze/presets", async () => {
vi.mocked(get).mockResolvedValue({
status: "success",
presets: [
{ name: "catalog_metadata", description: "Catalog enrichment", requiresAudio: true, responseFormat: "json" },
{ name: "mood_tags", description: "Mood tags", requiresAudio: true, responseFormat: "json" },
],
});

await songsCommand.parseAsync(["presets"], { from: "user" });

expect(get).toHaveBeenCalledWith("/api/songs/analyze/presets");
expect(logSpy).toHaveBeenCalled();
});

it("prints JSON with --json flag", async () => {
const presets = [
{ name: "catalog_metadata", description: "Catalog enrichment" },
];
vi.mocked(get).mockResolvedValue({ status: "success", presets });

await songsCommand.parseAsync(["presets", "--json"], { from: "user" });

expect(logSpy).toHaveBeenCalledWith(JSON.stringify(presets, null, 2));
});
});
2 changes: 2 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { whoamiCommand } from "./commands/whoami.js";
import { artistsCommand } from "./commands/artists.js";
import { chatsCommand } from "./commands/chats.js";
import { sandboxesCommand } from "./commands/sandboxes.js";
import { songsCommand } from "./commands/songs.js";
import { notificationsCommand } from "./commands/notifications.js";
import { orgsCommand } from "./commands/orgs.js";

Expand All @@ -21,6 +22,7 @@ program
program.addCommand(whoamiCommand);
program.addCommand(artistsCommand);
program.addCommand(chatsCommand);
program.addCommand(songsCommand);
program.addCommand(notificationsCommand);
program.addCommand(sandboxesCommand);
program.addCommand(orgsCommand);
Expand Down
83 changes: 83 additions & 0 deletions src/commands/songs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Command } from "commander";
import { get, post } from "../client.js";
import { printJson, printError } from "../output.js";

const analyzeCommand = new Command("analyze")
.description("Analyze music using a preset or custom prompt")
.option("--prompt <text>", "Custom text prompt (omit when using --preset)")
.option("--preset <name>", "Use a curated analysis preset (e.g. catalog_metadata, full_report)")
.option("--audio <url>", "Public URL to an audio file (MP3, WAV, FLAC)")
.option("--max-tokens <n>", "Max tokens to generate (default 512)", parseInt)
.option("--json", "Output as JSON")
.action(async (opts) => {
try {
if (!opts.prompt && !opts.preset) {
console.error("Error: Provide --prompt <text> or use --preset <name>. Run 'recoup songs presets' to see available presets.");
process.exit(1);
}

const body: Record<string, unknown> = {};
if (opts.preset) body.preset = opts.preset;
if (opts.prompt) body.prompt = opts.prompt;
if (opts.audio) body.audio_url = opts.audio;
if (opts.maxTokens) body.max_new_tokens = opts.maxTokens;

const data = await post("/api/songs/analyze", body);

if (opts.json) {
printJson(data);
} else if (data.report) {
// full_report mode — print each section
const report = data.report as Record<string, unknown>;
for (const [key, value] of Object.entries(report)) {
console.log(`\n${"=".repeat(50)}`);
console.log(` ${key.toUpperCase().replace(/_/g, " ")}`);
console.log(`${"=".repeat(50)}`);
if (typeof value === "string") {
console.log(value);
} else {
console.log(JSON.stringify(value, null, 2));
}
}
console.log(`\n(${data.elapsed_seconds}s total)`);
} else if (typeof data.response === "object") {
// JSON preset — pretty print
console.log(JSON.stringify(data.response, null, 2));
console.log(`\n(${data.elapsed_seconds}s)`);
} else {
// Text response
console.log(data.response as string);
console.log(`\n(${data.elapsed_seconds}s)`);
}
} catch (err) {
printError((err as Error).message);
}
});

const presetsCommand = new Command("presets")
.description("List available analysis presets")
.option("--json", "Output as JSON")
.action(async (opts) => {
try {
const data = await get("/api/songs/analyze/presets");
const presets = (data.presets as Record<string, unknown>[]) || [];

if (opts.json) {
printJson(presets);
} else {
for (const p of presets) {
const audio = p.requiresAudio ? " [requires audio]" : "";
const format = p.responseFormat === "json" ? " (JSON)" : " (text)";
console.log(` ${p.name}${format}${audio}`);
console.log(` ${p.description}\n`);
}
}
} catch (err) {
printError((err as Error).message);
}
});

export const songsCommand = new Command("songs")
.description("Song analysis tools")
.addCommand(analyzeCommand)
.addCommand(presetsCommand);