import type { IDataObject, IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription, IRequestOptions, } from "n8n-workflow"; import { NodeOperationError } from "n8n-workflow"; const LAYOUT_TEMPLATES = [ "COVER_LEFT_TEXT_RIGHT", "COVER_TOP_TEXT_BOTTOM", "COVER_RIGHT_TEXT_LEFT", "CENTERED_COMPACT", "LOWER_LEFT_COVER_TEXT", "LOWER_RIGHT_COVER_TEXT", ] as const; const RESOLUTIONS = [ "1920x1080", "1280x720", "854x480", "720x720", "640x360", "426x240", ] as const; const PRIVACY = ["PUBLIC", "PRIVATE", "UNLISTED"] as const; const WATERMARK_MODES = ["none", "default", "text", "logo"] as const; const WATERMARK_POSITIONS = [ "top-left", "top-right", "bottom-left", "bottom-right", "center", ] as const; const FONT_KEYS = [ "system", "inter", "montserrat", "roboto", "oswald", "playfair", "custom", ] as const; /** * Full Songs2VID REST surface for n8n — every documented /api/v1 endpoint. * Optional fields live under Additional Fields collections. */ export class Songs2Vid implements INodeType { description: INodeTypeDescription = { displayName: "Songs2VID", name: "songs2Vid", icon: "file:songs2vid.svg", group: ["transform"], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: "Upload media, create YouTube renders, poll jobs, manage playlists & API keys", defaults: { name: "Songs2VID", }, inputs: ["main"], outputs: ["main"], credentials: [ { name: "songs2VidApi", required: true, displayOptions: { show: { resource: [ "file", "render", "playlist", "apiKey", ], }, }, }, { name: "songs2VidApi", required: false, displayOptions: { show: { resource: ["discovery"], }, }, }, ], requestDefaults: { baseURL: '={{($credentials.baseUrl || "https://songs2vid.com").replace(/\\/$/, "")}}', headers: { Accept: "application/json", }, }, properties: [ { displayName: "Resource", name: "resource", type: "options", noDataExpression: true, options: [ { name: "API Key", value: "apiKey" }, { name: "Discovery", value: "discovery" }, { name: "File", value: "file" }, { name: "Playlist", value: "playlist" }, { name: "Render", value: "render" }, ], default: "render", }, // ── Discovery ────────────────────────────────────────────── { displayName: "Operation", name: "operation", type: "options", noDataExpression: true, displayOptions: { show: { resource: ["discovery"] } }, options: [ { name: "Get API Catalog", value: "getCatalog", description: "GET /api/v1 — endpoints, limits, layout templates (no auth)", action: "Get API catalog", }, ], default: "getCatalog", }, // ── File ─────────────────────────────────────────────────── { displayName: "Operation", name: "operation", type: "options", noDataExpression: true, displayOptions: { show: { resource: ["file"] } }, options: [ { name: "Upload", value: "upload", description: "POST /api/v1/upload — image | audio | logo | font", action: "Upload a file", }, ], default: "upload", }, { displayName: "Binary Property", name: "binaryPropertyName", type: "string", default: "data", required: true, displayOptions: { show: { resource: ["file"], operation: ["upload"] }, }, description: "Name of the binary property that holds the file", }, { displayName: "Type", name: "uploadType", type: "options", required: true, options: [ { name: "Image (cover)", value: "image" }, { name: "Audio", value: "audio" }, { name: "Logo (PNG watermark)", value: "logo" }, { name: "Font (.ttf / .otf)", value: "font" }, ], default: "audio", displayOptions: { show: { resource: ["file"], operation: ["upload"] }, }, }, // ── Render ───────────────────────────────────────────────── { displayName: "Operation", name: "operation", type: "options", noDataExpression: true, displayOptions: { show: { resource: ["render"] } }, options: [ { name: "Create Render", value: "create", description: "POST /api/v1/render — start encode + YouTube upload", action: "Create a render", }, { name: "Create Job (alias)", value: "createJob", description: "POST /api/v1/jobs — identical to Create Render", action: "Create a job", }, { name: "Create Batch", value: "createBatch", description: "POST /api/v1/jobs/batch — one-shot multipart (no webhookUrl)", action: "Create a batch job", }, { name: "Get Render Status", value: "get", description: "GET /api/v1/jobs/:id", action: "Get render status", }, { name: "List Renders", value: "list", description: "GET /api/v1/jobs (or /api/v1/render)", action: "List renders", }, ], default: "create", }, { displayName: "Use Render Alias Path", name: "useRenderAlias", type: "boolean", default: false, displayOptions: { show: { resource: ["render"], operation: ["list"] }, }, description: "If true, call GET /api/v1/render instead of /api/v1/jobs", }, { displayName: "Job ID", name: "jobId", type: "string", required: true, default: "", displayOptions: { show: { resource: ["render"], operation: ["get"] }, }, }, { displayName: "Limit", name: "limit", type: "number", typeOptions: { minValue: 1, maxValue: 100 }, default: 20, displayOptions: { show: { resource: ["render"], operation: ["list"] }, }, description: "Max jobs to return (default 20, max 100)", }, { displayName: "Image Path", name: "imagePath", type: "string", required: true, default: "", displayOptions: { show: { resource: ["render"], operation: ["create", "createJob"], }, }, description: "Server path from Upload (type=image)", }, { displayName: "Audio Path", name: "audioPath", type: "string", required: true, default: "", displayOptions: { show: { resource: ["render"], operation: ["create", "createJob"], }, }, description: "Server path from Upload (type=audio)", }, { displayName: "Audio Filename", name: "audioFilename", type: "string", required: true, default: "", displayOptions: { show: { resource: ["render"], operation: ["create", "createJob"], }, }, }, { displayName: "YouTube Title", name: "title", type: "string", default: "", displayOptions: { show: { resource: ["render"], operation: ["create", "createJob"], }, }, description: "YouTube video title. Falls back to artist - songTitle when empty.", }, { displayName: "Privacy", name: "privacy", type: "options", options: PRIVACY.map((p) => ({ name: p, value: p })), default: "UNLISTED", required: true, displayOptions: { show: { resource: ["render"], operation: ["create", "createJob"], }, }, }, { displayName: "Resolution", name: "resolution", type: "options", options: RESOLUTIONS.map((r) => ({ name: r, value: r })), default: "1920x1080", required: true, displayOptions: { show: { resource: ["render"], operation: ["create", "createJob"], }, }, }, { displayName: "Additional Fields", name: "additionalFields", type: "collection", placeholder: "Add Field", default: {}, displayOptions: { show: { resource: ["render"], operation: ["create", "createJob"], }, }, options: [ { displayName: "Webhook URL", name: "webhookUrl", type: "string", default: "", description: "Absolute https URL — Songs2VID POSTs job.item.* / job.* events", }, { displayName: "Song Title (on-video)", name: "songTitle", type: "string", default: "", description: "Art-track on-video title (max 120)", }, { displayName: "Artist (on-video)", name: "artist", type: "string", default: "", description: "Art-track on-video artist (max 80)", }, { displayName: "Description", name: "description", type: "string", default: "", typeOptions: { rows: 3 }, }, { displayName: "Tags", name: "tags", type: "string", default: "", description: "Comma-separated YouTube tags", }, { displayName: "Category ID", name: "categoryId", type: "string", default: "10", description: "YouTube category (10 = Music)", }, { displayName: "Notify Subscribers", name: "notifySubscribers", type: "boolean", default: false, }, { displayName: "Made for Kids", name: "madeForKids", type: "boolean", default: false, }, { displayName: "Embeddable", name: "embeddable", type: "boolean", default: true, }, { displayName: "Creative Commons", name: "creativeCommons", type: "boolean", default: false, }, { displayName: "Include Watermark", name: "includeWatermark", type: "boolean", default: false, }, { displayName: "Per-track Cover Path", name: "itemImagePath", type: "string", default: "", description: "Overrides job imagePath for this track", }, { displayName: "Background Image Path", name: "backgroundImagePath", type: "string", default: "", description: "Lower-corner templates only", }, { displayName: "Playlist ID", name: "playlistId", type: "string", default: "", }, { displayName: "Create Playlist Title", name: "createPlaylistTitle", type: "string", default: "", description: "If set, creates a playlist and attaches all videos", }, { displayName: "Create Playlist Description", name: "createPlaylistDescription", type: "string", default: "", }, { displayName: "Create Playlist Privacy", name: "createPlaylistPrivacy", type: "options", options: [ { name: "public", value: "public" }, { name: "unlisted", value: "unlisted" }, { name: "private", value: "private" }, ], default: "private", }, { displayName: "Layout Template", name: "layoutTemplate", type: "options", options: [ { name: "(classic letterbox)", value: "" }, ...LAYOUT_TEMPLATES.map((t) => ({ name: t, value: t })), ], default: "", }, { displayName: "Blur Amount", name: "blurAmount", type: "number", typeOptions: { minValue: 0, maxValue: 100 }, default: 55, }, { displayName: "Blur Opacity", name: "blurOpacity", type: "number", typeOptions: { minValue: 0, maxValue: 100 }, default: 100, }, { displayName: "Blur Fill (letterbox)", name: "blurFill", type: "boolean", default: false, }, { displayName: "Text Padding", name: "textPadding", type: "number", typeOptions: { minValue: 16, maxValue: 120 }, default: 48, }, { displayName: "Title / Artist Gap", name: "titleArtistGap", type: "number", typeOptions: { minValue: 0, maxValue: 64 }, default: 10, }, { displayName: "Title Bold", name: "titleBold", type: "boolean", default: true, }, { displayName: "Text Offset X", name: "textOffsetX", type: "number", typeOptions: { minValue: -120, maxValue: 120 }, default: 0, }, { displayName: "Text Offset Y", name: "textOffsetY", type: "number", typeOptions: { minValue: -120, maxValue: 120 }, default: 0, }, { displayName: "Watermark Mode", name: "watermarkMode", type: "options", options: WATERMARK_MODES.map((m) => ({ name: m, value: m })), default: "none", }, { displayName: "Watermark Text", name: "watermarkText", type: "string", default: "", }, { displayName: "Watermark Logo Path", name: "watermarkLogoPath", type: "string", default: "", }, { displayName: "Watermark Position", name: "watermarkPosition", type: "options", options: WATERMARK_POSITIONS.map((p) => ({ name: p, value: p })), default: "bottom-right", }, { displayName: "Watermark Offset X", name: "watermarkOffsetX", type: "number", typeOptions: { minValue: 0, maxValue: 200 }, default: 20, }, { displayName: "Watermark Offset Y", name: "watermarkOffsetY", type: "number", typeOptions: { minValue: 0, maxValue: 200 }, default: 20, }, { displayName: "Font Key", name: "fontKey", type: "options", options: FONT_KEYS.map((k) => ({ name: k, value: k })), default: "montserrat", }, { displayName: "Custom Font Path", name: "fontPath", type: "string", default: "", description: "Required when Font Key = custom", }, { displayName: "Extra Items JSON", name: "extraItemsJson", type: "json", default: "[]", description: "Optional array of additional { audioPath, audioFilename, metadata } objects for multi-track jobs", }, ], }, // Batch fields { displayName: "Cover Binary Property", name: "batchImageBinary", type: "string", default: "cover", required: true, displayOptions: { show: { resource: ["render"], operation: ["createBatch"] }, }, }, { displayName: "Audio Binary Property", name: "batchAudioBinary", type: "string", default: "audio", required: true, displayOptions: { show: { resource: ["render"], operation: ["createBatch"] }, }, description: "Binary field name for audio. For multiple files, pass items with this property or use Audio Binary Properties list.", }, { displayName: "Additional Fields", name: "batchAdditionalFields", type: "collection", placeholder: "Add Field", default: {}, displayOptions: { show: { resource: ["render"], operation: ["createBatch"] }, }, options: [ { displayName: "Metadata JSON", name: "metadataJson", type: "json", default: '{"defaults":{"privacy":"PUBLIC","resolution":"1920x1080"},"items":[]}', description: "Multipart metadata string: { defaults?, items?, createPlaylist? }", }, ], }, // ── Playlist ─────────────────────────────────────────────── { displayName: "Operation", name: "operation", type: "options", noDataExpression: true, displayOptions: { show: { resource: ["playlist"] } }, options: [ { name: "List", value: "list", description: "GET /api/v1/playlists", action: "List playlists", }, { name: "Create", value: "create", description: "POST /api/v1/playlists", action: "Create a playlist", }, ], default: "list", }, { displayName: "Title", name: "playlistTitle", type: "string", required: true, default: "", displayOptions: { show: { resource: ["playlist"], operation: ["create"] }, }, }, { displayName: "Additional Fields", name: "playlistAdditionalFields", type: "collection", placeholder: "Add Field", default: {}, displayOptions: { show: { resource: ["playlist"], operation: ["create"] }, }, options: [ { displayName: "Description", name: "description", type: "string", default: "", }, { displayName: "Privacy", name: "privacy", type: "options", options: [ { name: "public", value: "public" }, { name: "unlisted", value: "unlisted" }, { name: "private", value: "private" }, ], default: "private", }, ], }, // ── API Key ──────────────────────────────────────────────── { displayName: "Operation", name: "operation", type: "options", noDataExpression: true, displayOptions: { show: { resource: ["apiKey"] } }, options: [ { name: "List", value: "list", description: "GET /api/v1/user/api-keys", action: "List API keys", }, { name: "Create", value: "create", description: "POST /api/v1/user/api-keys", action: "Create an API key", }, { name: "Delete", value: "delete", description: "DELETE /api/v1/user/api-keys", action: "Delete API key(s)", }, ], default: "list", }, { displayName: "Name", name: "apiKeyName", type: "string", default: "Default", displayOptions: { show: { resource: ["apiKey"], operation: ["create"] }, }, description: "Optional label (max 64)", }, { displayName: "Additional Fields", name: "apiKeyAdditionalFields", type: "collection", placeholder: "Add Field", default: {}, displayOptions: { show: { resource: ["apiKey"], operation: ["delete"] }, }, options: [ { displayName: "Key ID", name: "id", type: "string", default: "", description: "Omit to revoke all keys", }, ], }, ], }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const resource = this.getNodeParameter("resource", 0) as string; const operation = this.getNodeParameter("operation", 0) as string; const credentials = await this.getCredentials("songs2VidApi").catch( () => null, ); const baseUrl = String( credentials?.baseUrl || "https://songs2vid.com", ).replace(/\/$/, ""); for (let i = 0; i < items.length; i++) { try { let response: IDataObject | IDataObject[] | undefined; if (resource === "discovery" && operation === "getCatalog") { response = (await this.helpers.request({ method: "GET", url: `${baseUrl}/api/v1`, json: true, } as IRequestOptions)) as IDataObject; } else if (resource === "file" && operation === "upload") { const binaryPropertyName = this.getNodeParameter( "binaryPropertyName", i, ) as string; const uploadType = this.getNodeParameter("uploadType", i) as string; const binaryData = this.helpers.assertBinaryData(i, binaryPropertyName); const buffer = await this.helpers.getBinaryDataBuffer( i, binaryPropertyName, ); response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "POST", url: `${baseUrl}/api/v1/upload`, formData: { file: { value: buffer, options: { filename: binaryData.fileName || "upload.bin", contentType: binaryData.mimeType || "application/octet-stream", }, }, type: uploadType, }, json: true, } as IRequestOptions, )) as IDataObject; } else if ( resource === "render" && (operation === "create" || operation === "createJob") ) { response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "POST", url: `${baseUrl}/api/v1/${operation === "create" ? "render" : "jobs"}`, body: buildCreateJobBody(this, i), json: true, } as IRequestOptions, )) as IDataObject; } else if (resource === "render" && operation === "get") { const jobId = this.getNodeParameter("jobId", i) as string; response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "GET", url: `${baseUrl}/api/v1/jobs/${encodeURIComponent(jobId)}`, json: true, } as IRequestOptions, )) as IDataObject; } else if (resource === "render" && operation === "list") { const limit = this.getNodeParameter("limit", i) as number; const useAlias = this.getNodeParameter( "useRenderAlias", i, false, ) as boolean; response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "GET", url: `${baseUrl}/api/v1/${useAlias ? "render" : "jobs"}`, qs: { limit }, json: true, } as IRequestOptions, )) as IDataObject; } else if (resource === "render" && operation === "createBatch") { const imageProp = this.getNodeParameter( "batchImageBinary", i, ) as string; const audioProp = this.getNodeParameter( "batchAudioBinary", i, ) as string; const extra = this.getNodeParameter( "batchAdditionalFields", i, {}, ) as IDataObject; const imageBin = this.helpers.assertBinaryData(i, imageProp); const imageBuf = await this.helpers.getBinaryDataBuffer(i, imageProp); const audioBin = this.helpers.assertBinaryData(i, audioProp); const audioBuf = await this.helpers.getBinaryDataBuffer(i, audioProp); const formData: IDataObject = { image: { value: imageBuf, options: { filename: imageBin.fileName || "cover.jpg", contentType: imageBin.mimeType || "image/jpeg", }, }, audio: { value: audioBuf, options: { filename: audioBin.fileName || "track.mp3", contentType: audioBin.mimeType || "audio/mpeg", }, }, }; if (extra.metadataJson) { formData.metadata = typeof extra.metadataJson === "string" ? extra.metadataJson : JSON.stringify(extra.metadataJson); } response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "POST", url: `${baseUrl}/api/v1/jobs/batch`, formData, json: true, } as IRequestOptions, )) as IDataObject; } else if (resource === "playlist" && operation === "list") { response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "GET", url: `${baseUrl}/api/v1/playlists`, json: true, } as IRequestOptions, )) as IDataObject; } else if (resource === "playlist" && operation === "create") { const title = this.getNodeParameter("playlistTitle", i) as string; const extra = this.getNodeParameter( "playlistAdditionalFields", i, {}, ) as IDataObject; const body: IDataObject = { title }; if (extra.description) body.description = extra.description; if (extra.privacy) body.privacy = extra.privacy; response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "POST", url: `${baseUrl}/api/v1/playlists`, body, json: true, } as IRequestOptions, )) as IDataObject; } else if (resource === "apiKey" && operation === "list") { response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "GET", url: `${baseUrl}/api/v1/user/api-keys`, json: true, } as IRequestOptions, )) as IDataObject; } else if (resource === "apiKey" && operation === "create") { const name = this.getNodeParameter("apiKeyName", i) as string; response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "POST", url: `${baseUrl}/api/v1/user/api-keys`, body: { name }, json: true, } as IRequestOptions, )) as IDataObject; } else if (resource === "apiKey" && operation === "delete") { const extra = this.getNodeParameter( "apiKeyAdditionalFields", i, {}, ) as IDataObject; const qs: IDataObject = {}; if (extra.id) qs.id = extra.id; response = (await this.helpers.requestWithAuthentication.call( this, "songs2VidApi", { method: "DELETE", url: `${baseUrl}/api/v1/user/api-keys`, qs, json: true, } as IRequestOptions, )) as IDataObject; } else { throw new NodeOperationError( this.getNode(), `Unknown resource/operation: ${resource}/${operation}`, { itemIndex: i }, ); } const executionData = this.helpers.constructExecutionMetaData( this.helpers.returnJsonArray(response as IDataObject), { itemData: { item: i } }, ); returnData.push(...executionData); } catch (error) { if (this.continueOnFail()) { returnData.push({ json: { error: error instanceof Error ? error.message : String(error), }, pairedItem: { item: i }, }); continue; } throw error; } } return [returnData]; } } function buildCreateJobBody( ctx: IExecuteFunctions, itemIndex: number, ): IDataObject { const imagePath = ctx.getNodeParameter("imagePath", itemIndex) as string; const audioPath = ctx.getNodeParameter("audioPath", itemIndex) as string; const audioFilename = ctx.getNodeParameter( "audioFilename", itemIndex, ) as string; const title = ctx.getNodeParameter("title", itemIndex) as string; const privacy = ctx.getNodeParameter("privacy", itemIndex) as string; const resolution = ctx.getNodeParameter("resolution", itemIndex) as string; const extra = ctx.getNodeParameter( "additionalFields", itemIndex, {}, ) as IDataObject; const metadata: IDataObject = { title, privacy, resolution, }; const passthrough = [ "songTitle", "artist", "description", "tags", "categoryId", "notifySubscribers", "madeForKids", "embeddable", "creativeCommons", "includeWatermark", "backgroundImagePath", "playlistId", ] as const; for (const key of passthrough) { const value = extra[key]; if (value !== undefined && value !== null && value !== "") { metadata[key] = value; } } if (extra.itemImagePath) { metadata.imagePath = extra.itemImagePath; } if (extra.layoutTemplate) { metadata.layout = { template: extra.layoutTemplate, blurAmount: extra.blurAmount ?? 55, blurOpacity: extra.blurOpacity ?? 100, blurFill: extra.blurFill ?? false, textPadding: extra.textPadding ?? 48, titleArtistGap: extra.titleArtistGap ?? 10, titleBold: extra.titleBold ?? true, textOffsetX: extra.textOffsetX ?? 0, textOffsetY: extra.textOffsetY ?? 0, }; } else if (extra.blurFill === true) { metadata.layout = { blurFill: true, blurAmount: extra.blurAmount ?? 55, blurOpacity: extra.blurOpacity ?? 100, }; } if ( extra.watermarkMode || extra.watermarkText || extra.watermarkLogoPath || extra.fontKey ) { const watermark: IDataObject = { mode: (extra.watermarkMode as string) || "none", position: (extra.watermarkPosition as string) || "bottom-right", offsetX: extra.watermarkOffsetX ?? 20, offsetY: extra.watermarkOffsetY ?? 20, fontKey: (extra.fontKey as string) || "montserrat", }; if (extra.watermarkText) watermark.text = extra.watermarkText; if (extra.watermarkLogoPath) watermark.logoPath = extra.watermarkLogoPath; if (extra.fontPath) watermark.fontPath = extra.fontPath; metadata.watermark = watermark; } const items: IDataObject[] = [ { audioPath, audioFilename, metadata, }, ]; if (extra.extraItemsJson) { const parsed = typeof extra.extraItemsJson === "string" ? (JSON.parse(extra.extraItemsJson) as IDataObject[]) : (extra.extraItemsJson as IDataObject[]); if (Array.isArray(parsed)) { items.push(...parsed); } } const body: IDataObject = { imagePath, items, }; if (extra.webhookUrl) { body.webhookUrl = extra.webhookUrl; } if (extra.createPlaylistTitle) { body.createPlaylist = { title: extra.createPlaylistTitle, description: extra.createPlaylistDescription || undefined, privacy: extra.createPlaylistPrivacy || "private", }; } return body; }