feat: Initial structure for Suno AI n8n node

I've set up the foundational boilerplate for the Suno AI integration.

Key changes include:
- Restructured directories for nodes, credentials, interfaces, utils, tests, and docs.
- Renamed and updated example files to Suno-specific names and conventions (SunoApi.credentials.ts, Suno.node.ts).
- Updated package.json and root README.md for the Suno AI node.
- Created .env.example with placeholders for Suno environment variables.
- Added a dev-log.md with initial notes on authentication research strategy.
- Scaffolded utils/sunoApi.ts with placeholder API functions and JSDoc comments.
- Scaffolded nodes/Suno/Suno.node.ts with operations, properties, execute routing, and a placeholder SVG icon.
- Scaffolded nodes/Suno/SunoTrigger.node.ts with a basic trigger structure and properties.
- Defined initial TypeScript types in interfaces/SunoTypes.ts for common data structures (SunoTrack, SunoJob, etc.).
- Created placeholder README.md files in new subdirectories.

This commit establishes the project structure and lays the groundwork for implementing Suno AI API interactions and node functionality.
This commit is contained in:
google-labs-jules[bot] 2025-05-23 17:03:46 +00:00
commit 6c69a287fe
20 changed files with 684 additions and 449 deletions

212
nodes/Suno/Suno.node.ts Normal file
View file

@ -0,0 +1,212 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
// Assuming INodeProperties is needed for properties definition
INodeProperties,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
// TODO: Import functions from sunoApi.ts when they are implemented
// import * as sunoApi from '../../utils/sunoApi';
export class SunoNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Suno',
name: 'suno',
icon: 'file:suno.svg',
group: ['ai'],
version: 1,
subtitle: '={{$parameter["operation"]}}',
description: 'Integrates with Suno AI for music generation',
defaults: {
name: 'Suno',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'sunoApi', // Matches the name in SunoApi.credentials.ts
required: true,
},
],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Generate Song from Prompt',
value: 'generateSongFromPrompt',
description: 'Create a new song based on a text prompt',
action: 'Generate song from prompt',
},
{
name: 'Upload Track Reference',
value: 'uploadTrackReference',
description: 'Upload a reference track for generation',
action: 'Upload track reference',
},
{
name: 'Get Track Status',
value: 'getTrackStatus',
description: 'Get the status of a generated track',
action: 'Get track status',
},
{
name: 'Download Track',
value: 'downloadTrack',
description: 'Download a generated track',
action: 'Download track',
},
{
name: 'List Previous Songs',
value: 'listPreviousSongs',
description: 'List previously generated songs',
action: 'List previous songs',
},
],
default: 'generateSongFromPrompt',
},
// Properties for 'generateSongFromPrompt'
{
displayName: 'Prompt',
name: 'prompt',
type: 'string',
default: '',
placeholder: 'e.g., Epic orchestral score for a space battle',
description: 'The text prompt to generate music from',
displayOptions: {
show: {
operation: ['generateSongFromPrompt'],
},
},
},
// Properties for 'getTrackStatus' and 'downloadTrack'
{
displayName: 'Track ID',
name: 'trackId',
type: 'string',
default: '',
required: true,
placeholder: 'Enter Track ID',
description: 'The ID of the track',
displayOptions: {
show: {
operation: ['getTrackStatus', 'downloadTrack'],
},
},
},
// Properties for 'uploadTrackReference'
{
displayName: 'File Path',
name: 'filePath',
type: 'string',
default: '',
required: true,
placeholder: '/path/to/your/audio.mp3',
description: 'Path to the audio file to upload as reference',
displayOptions: {
show: {
operation: ['uploadTrackReference'],
},
},
},
] as INodeProperties[], // Cast to INodeProperties[]
};
/**
* Placeholder method for generating a song from a prompt.
* @param {IExecuteFunctions} this - The execution context.
* @returns {Promise<INodeExecutionData[][]>}
*/
async generateSongFromPrompt(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const prompt = this.getNodeParameter('prompt', 0, '') as string;
// TODO: Call the appropriate sunoApi.ts function (sunoApi.submitPrompt) and handle response
console.log('Executing generateSongFromPrompt with prompt:', prompt);
// For now, return empty data
return [this.prepareOutputData([])];
}
/**
* Placeholder method for uploading a track reference.
* @param {IExecuteFunctions} this - The execution context.
* @returns {Promise<INodeExecutionData[][]>}
*/
async uploadTrackReference(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const filePath = this.getNodeParameter('filePath', 0, '') as string;
// TODO: Call the appropriate sunoApi.ts function (sunoApi.uploadReferenceTrack) and handle response
console.log('Executing uploadTrackReference with filePath:', filePath);
return [this.prepareOutputData([])];
}
/**
* Placeholder method for getting track status.
* @param {IExecuteFunctions} this - The execution context.
* @returns {Promise<INodeExecutionData[][]>}
*/
async getTrackStatus(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const trackId = this.getNodeParameter('trackId', 0, '') as string;
// TODO: Call the appropriate sunoApi.ts function (sunoApi.pollJobStatus or similar) and handle response
console.log('Executing getTrackStatus with trackId:', trackId);
return [this.prepareOutputData([])];
}
/**
* Placeholder method for downloading a track.
* @param {IExecuteFunctions} this - The execution context.
* @returns {Promise<INodeExecutionData[][]>}
*/
async downloadTrack(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const trackId = this.getNodeParameter('trackId', 0, '') as string;
// TODO: Call the appropriate sunoApi.ts function (sunoApi.downloadTrack) and handle response
// TODO: Consider how to handle binary data output in n8n
console.log('Executing downloadTrack with trackId:', trackId);
return [this.prepareOutputData([])];
}
/**
* Placeholder method for listing previous songs.
* @param {IExecuteFunctions} this - The execution context.
* @returns {Promise<INodeExecutionData[][]>}
*/
async listPreviousSongs(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
// TODO: Call the appropriate sunoApi.ts function (sunoApi.listPreviousSongs) and handle response
console.log('Executing listPreviousSongs');
return [this.prepareOutputData([])];
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const operation = this.getNodeParameter('operation', 0, '') as string;
try {
switch (operation) {
case 'generateSongFromPrompt':
return this.generateSongFromPrompt(this);
case 'uploadTrackReference':
return this.uploadTrackReference(this);
case 'getTrackStatus':
return this.getTrackStatus(this);
case 'downloadTrack':
return this.downloadTrack(this);
case 'listPreviousSongs':
return this.listPreviousSongs(this);
default:
throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not supported.`);
}
} catch (error) {
// This node should use NodeOperationError when an error occurs directly within this node's execution logic
if (this.continueOnFail()) {
// Return error data as per n8n's guidelines for allowing the workflow to continue
const item = this.getInputData(0)[0]; // Get the first item if available, otherwise undefined
return [this.prepareOutputData([{ json: {}, error: error, pairedItem: item ? { item: 0 } : undefined }])];
} else {
// If not continuing on fail, rethrow the error to halt the workflow
throw error; // NodeOperationError should already be structured correctly
}
}
}
}

View file

@ -0,0 +1,132 @@
import type {
INodeType,
INodeTypeDescription,
ITriggerFunctions,
ITriggerResponse,
} from 'n8n-workflow';
export class SunoTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Suno Trigger',
name: 'sunoTrigger',
icon: 'file:suno.svg', // Re-use the icon
group: ['trigger', 'ai'],
version: 1,
description: 'Triggers when a Suno AI event occurs',
defaults: {
name: 'Suno Trigger',
},
inputs: [], // Triggers usually do not have inputs
outputs: ['main'], // Main output for triggered data
credentials: [
{
name: 'sunoApi',
required: true,
},
],
properties: [
// Define properties for the trigger
{
displayName: 'Trigger Event',
name: 'triggerEvent',
type: 'options',
options: [
{
name: 'Track Generation Complete',
value: 'trackGenerationComplete',
description: 'Triggers when a specific track finishes generation',
},
{
name: 'New Song Available',
value: 'newSongAvailable',
description: 'Triggers when any new song is available in the library (polling)',
},
],
default: 'trackGenerationComplete',
description: 'The Suno event that will trigger this node',
},
{
displayName: 'Track ID',
name: 'trackId',
type: 'string',
default: '',
description: 'The ID of the track to monitor for completion',
displayOptions: {
show: {
triggerEvent: ['trackGenerationComplete'],
},
},
},
{
displayName: 'Polling Interval (minutes)',
name: 'pollingInterval',
type: 'number',
default: 5,
description: 'How often to check for new songs (if applicable)',
displayOptions: {
show: {
triggerEvent: ['newSongAvailable'],
},
},
},
],
};
// Placeholder for trigger methods
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse | undefined> {
// const credentials = await this.getCredentials('sunoApi');
// const triggerEvent = this.getNodeParameter('triggerEvent') as string;
// const trackId = this.getNodeParameter('trackId') as string;
// const pollingInterval = this.getNodeParameter('pollingInterval') as number;
// TODO: Implement actual trigger logic based on triggerEvent
// For 'trackGenerationComplete', this might involve polling pollJobStatus(trackId)
// For 'newSongAvailable', this might involve polling listPreviousSongs() and checking for new entries
// If webhook based, this method would be different,
// this.emit([this.helpers.returnJsonArray([{ eventData: 'example' }])]);
// this.on('close', () => { /* remove webhook */ });
// return { webhookId: 'your-webhook-id' };
// For polling triggers, this method might not be used directly if using manualTriggerFunction
if (this.manualTriggerFunction) { // Corrected placeholder name
// Manual trigger logic for polling
// This will be called by n8n based on the schedule if `manualTriggerFunction` is defined
// Example:
// const items = await pollSunoApiForUpdates();
// if (items.length > 0) {
// return {
// items: this.helpers.returnJsonArray(items),
// };
// }
// return undefined; // No new items
}
// For now, returning undefined as it's a placeholder
// For a polling trigger, you might set up an interval here or use manualTriggerFunction
// For a webhook trigger, you would register the webhook here.
return undefined;
}
// Example of how a manual trigger function might be structured for polling
// async manualTrigger(this: ITriggerFunctions): Promise<INodeExecutionData[][] | undefined> {
// const triggerEvent = this.getNodeParameter('triggerEvent') as string;
// // ... get other params and credentials
//
// if (triggerEvent === 'newSongAvailable') {
// console.log('Polling for new songs...');
// // const newSongs = await sunoApi.listPreviousSongs(credentials, /* potential pagination params */);
// // Check against previously seen songs (requires state management, complex for this scaffold)
// // For now, let's simulate finding one new song:
// // const simulatedNewSong = [{ id: 'new_track_123', title: 'A New Song', status: 'complete' }];
// // return [this.helpers.returnJsonArray(simulatedNewSong)];
// } else if (triggerEvent === 'trackGenerationComplete') {
// // const trackId = this.getNodeParameter('trackId') as string;
// // const status = await sunoApi.pollJobStatus(credentials, trackId);
// // if (status && status.status === 'complete') {
// // return [this.helpers.returnJsonArray([status])];
// // }
// }
// return undefined; // No trigger event
// }
}

1
nodes/Suno/suno.svg Normal file
View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="blue" /><text x="50" y="50" font-size="30" fill="white" text-anchor="middle" dy=".3em">Suno</text></svg>

After

Width:  |  Height:  |  Size: 204 B