Files
songs2vid/integrations/n8n/nodes/Songs2Vid/Songs2Vid.node.ts
T
Atakan Doğan Özban f9b2a997a2 Add n8n community node, /api/v1/render alias, and job webhooks for OSS automation.
Payment-free self-hosted builds keep full API access with optional webhookUrl callbacks and the published n8n-nodes-songs2vid package source under integrations/n8n.
2026-08-08 16:32:28 +02:00

1141 lines
33 KiB
TypeScript

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<INodeExecutionData[][]> {
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;
}