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.
This commit is contained in:
Atakan Doğan Özban
2026-08-08 16:32:28 +02:00
parent 848607f9e0
commit f9b2a997a2
27 changed files with 7333 additions and 14 deletions
+3 -1
View File
@@ -43,13 +43,15 @@ OSS does not force a free-tier SaaS badge paywall.
- 💧 Custom watermarks (badge, text, or logo) and curated or uploaded fonts - 💧 Custom watermarks (badge, text, or logo) and curated or uploaded fonts
- ▶️ YouTube playlists, privacy, tags, resolution, categories - ▶️ YouTube playlists, privacy, tags, resolution, categories
- 🔌 REST API for automation (keys under **Dashboard → Settings → API key**) - 🔌 REST API for automation (keys under **Dashboard → Settings → API key**)
- 🤖 n8n community node [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) + optional `webhookUrl` callbacks
- 🔓 Always-on self-hosted entitlements (no quota paywall) - 🔓 Always-on self-hosted entitlements (no quota paywall)
## 📚 API documentation ## 📚 API documentation
- In-repo: [docs/api/overview.md](./docs/api/overview.md) and [docs/api/endpoints.md](./docs/api/endpoints.md) - In-repo: [docs/api/overview.md](./docs/api/overview.md), [docs/api/endpoints.md](./docs/api/endpoints.md), [docs/n8n.md](./docs/n8n.md)
- Live site: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview) - Live site: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)
- Machine-readable discovery: `GET /api/v1` (no auth; no billing routes) - Machine-readable discovery: `GET /api/v1` (no auth; no billing routes)
- Node package source: [`integrations/n8n`](./integrations/n8n/)
## 🛠️ Local development ## 🛠️ Local development
+3
View File
@@ -35,12 +35,15 @@ export async function POST(req: NextRequest) {
const job = await createVideoJob(user, { const job = await createVideoJob(user, {
imagePath: body.imagePath, imagePath: body.imagePath,
items, items,
webhookUrl: body.webhookUrl ?? null,
}); });
return NextResponse.json({ return NextResponse.json({
jobId: job.id, jobId: job.id,
itemCount: job.items.length, itemCount: job.items.length,
status: job.status, status: job.status,
statusUrl: `/api/v1/jobs/${job.id}`,
webhookUrl: body.webhookUrl ?? null,
playlist: playlist playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy } ? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null, : null,
+5
View File
@@ -0,0 +1,5 @@
/**
* Alias for n8n / automation: POST|GET /api/v1/render → same as /api/v1/jobs
* OSS: no plan gate — Bearer API key + YouTube connected.
*/
export { POST, GET } from "../jobs/route";
+14 -4
View File
@@ -16,11 +16,14 @@ export async function GET() {
billing: false, billing: false,
notes: notes:
"OSS self-hosted: full entitlements, no Stripe/credits/paywall. In-repo docs: docs/api/", "OSS self-hosted: full entitlements, no Stripe/credits/paywall. In-repo docs: docs/api/",
webhooks:
"Optional webhookUrl on job create — HTTPS POST JSON on item/job terminal states for n8n",
guidance: { guidance: {
recommended: recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths", "For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/render (or /api/v1/jobs) with paths + optional webhookUrl",
batch: batch:
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'", "POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
polling: "GET /api/v1/jobs/:id until status is COMPLETED, FAILED, or PARTIAL",
}, },
layoutTemplates: LAYOUT_TEMPLATES.map((id) => ({ layoutTemplates: LAYOUT_TEMPLATES.map((id) => ({
id, id,
@@ -43,12 +46,19 @@ export async function GET() {
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)", "Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
body: "multipart/form-data: file, type (image|audio|logo|font)", body: "multipart/form-data: file, type (image|audio|logo|font)",
}, },
{
method: "POST",
path: "/api/v1/render",
description:
"Alias of /api/v1/jobs — create a render job (n8n-friendly). Optional webhookUrl for callbacks.",
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
},
{ {
method: "POST", method: "POST",
path: "/api/v1/jobs", path: "/api/v1/jobs",
description: description:
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers", "Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }", body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
}, },
{ {
method: "POST", method: "POST",
@@ -71,13 +81,13 @@ export async function GET() {
{ {
method: "GET", method: "GET",
path: "/api/v1/jobs", path: "/api/v1/jobs",
description: "List recent jobs", description: "List recent jobs (also available as GET /api/v1/render)",
query: "limit (default 20, max 100)", query: "limit (default 20, max 100)",
}, },
{ {
method: "GET", method: "GET",
path: "/api/v1/jobs/:id", path: "/api/v1/jobs/:id",
description: "Get job status and item details", description: "Get job status and item details (poll for n8n)",
}, },
], ],
docs: API_DOCS_URL, docs: API_DOCS_URL,
+50 -2
View File
@@ -84,16 +84,21 @@ curl -X POST "$BASE_URL/api/v1/upload" \
`audioTags` is present for MP3 when tags are readable; otherwise `null`. `audioTags` is present for MP3 when tags are readable; otherwise `null`.
## Create job from paths (recommended) ## Create job / render from paths (recommended)
Self-hosted unlocks per-track covers, custom watermarks/fonts, and art-track layouts. Max batch size: **100**. Watermarks are optional — nothing forces the default Songs2VID badge. Self-hosted unlocks per-track covers, custom watermarks/fonts, and art-track layouts. Max batch size: **100**. Watermarks are optional — nothing forces the default Songs2VID badge.
`POST /api/v1/render` is an **n8n-friendly alias** of `POST /api/v1/jobs` (identical body and response). `GET /api/v1/render` lists jobs like `GET /api/v1/jobs`.
Optional `webhookUrl` (absolute `http(s)` URL) makes Songs2VID POST JSON when items/jobs finish — preferred for n8n.
```bash ```bash
curl -X POST "$BASE_URL/api/v1/jobs" \ curl -X POST "$BASE_URL/api/v1/render" \
-H "Authorization: Bearer $API_KEY" \ -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"imagePath": "/uploads/.../cover.jpg", "imagePath": "/uploads/.../cover.jpg",
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
"items": [{ "items": [{
"audioPath": "/uploads/.../track.mp3", "audioPath": "/uploads/.../track.mp3",
"audioFilename": "track.mp3", "audioFilename": "track.mp3",
@@ -134,6 +139,8 @@ curl -X POST "$BASE_URL/api/v1/jobs" \
}' }'
``` ```
Equivalent path: `POST $BASE_URL/api/v1/jobs` with the same JSON.
### Success response ### Success response
```json ```json
@@ -141,10 +148,51 @@ curl -X POST "$BASE_URL/api/v1/jobs" \
"jobId": "clxxxxxxxx", "jobId": "clxxxxxxxx",
"itemCount": 1, "itemCount": 1,
"status": "PENDING", "status": "PENDING",
"statusUrl": "/api/v1/jobs/clxxxxxxxx",
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
"playlist": null "playlist": null
} }
``` ```
### Webhook payload
| `event` | Meaning |
|---------|---------|
| `job.item.completed` | One track finished (`youtubeVideoId` set) |
| `job.item.failed` | One track failed (`error` set) |
| `job.completed` | All items succeeded |
| `job.failed` | All items failed |
| `job.partial` | Mix of success and failure |
Community node: [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) — see [docs/n8n.md](../n8n.md) and [docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n).
<details>
<summary>Legacy example without webhook (same metadata shape)</summary>
```bash
curl -X POST "$BASE_URL/api/v1/jobs" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"imagePath": "/uploads/.../cover.jpg",
"items": [{
"audioPath": "/uploads/.../track.mp3",
"audioFilename": "track.mp3",
"metadata": {
"title": "My Artist - My Track (Official Audio)",
"songTitle": "My Track",
"artist": "My Artist",
"privacy": "PUBLIC",
"categoryId": "10",
"resolution": "1920x1080",
"includeWatermark": false
}
}]
}'
```
</details>
### Metadata fields ### Metadata fields
| Field | Type | Notes | | Field | Type | Notes |
+4 -2
View File
@@ -31,10 +31,12 @@ If a limit is hit, the response is **429** with `retryAfterSeconds` and a `Retry
### Recommended: two-step (especially 5+ audio files) ### Recommended: two-step (especially 5+ audio files)
1. Upload each file with `POST /api/v1/upload` 1. Upload each file with `POST /api/v1/upload`
2. Create the job with `POST /api/v1/jobs` (JSON paths) 2. Create the job with `POST /api/v1/render` (alias of `/api/v1/jobs`) — JSON paths + optional `webhookUrl`
This avoids huge multipart bodies. Max batch size is **100** tracks per job. This avoids huge multipart bodies. Max batch size is **100** tracks per job.
Prefer `webhookUrl` for n8n so long encodes do not block an HTTP Request node. Details: [n8n](../n8n.md).
### One-shot batch: small packs only ### One-shot batch: small packs only
`POST /api/v1/jobs/batch` accepts one cover image and a few audio files in a single multipart request. Large bodies often fail with: `POST /api/v1/jobs/batch` accepts one cover image and a few audio files in a single multipart request. Large bodies often fail with:
@@ -63,4 +65,4 @@ Returns the endpoint list, requirements, `layoutTemplates` (every art-track enum
## Next ## Next
See [Endpoints](./endpoints.md) for curl examples. Hosted HTML docs: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview). See [Endpoints](./endpoints.md) for curl examples. n8n: [docs/n8n.md](../n8n.md). Hosted HTML docs: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview).
+36
View File
@@ -0,0 +1,36 @@
# n8n integration (OSS)
Automate self-hosted Songs2VID with the community package **[`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid)**.
Full hosted guide: [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n)
## Install
In n8n → **Settings → Community nodes** → install:
```text
n8n-nodes-songs2vid
```
Or from this repo for local development:
```bash
cd integrations/n8n
npm install
npm run build
```
## Auth
1. Dashboard → **Settings → API key** → create `s2yt_live_…`
2. n8n credential **Songs2VID API**: paste key, Base URL = your instance (e.g. `http://localhost:3000`)
OSS has **no plan gate** — API keys work whenever YouTube is connected.
## Flow
1. **Upload** cover (`type=image`) and audio (`type=audio`)
2. **Create Render** (`POST /api/v1/render`) with paths + optional `webhookUrl`
3. Handle completion via n8n **Webhook** or poll `GET /api/v1/jobs/:id`
Package source: [`integrations/n8n`](../integrations/n8n/).
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
*.tgz
.env
.env.*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Songs2VID
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+89
View File
@@ -0,0 +1,89 @@
# n8n-nodes-songs2vid
Official community node for **[Songs2VID](https://songs2vid.com)** — turn audio + cover art into YouTube-ready videos from [n8n](https://n8n.io/).
**Documentation:** [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n) · in-repo [docs/n8n.md](../../docs/n8n.md)
Self-hosted OSS: set **Base URL** to your instance. API keys work without a paid plan (YouTube must be connected).
## Install
### From n8n (recommended)
1. Open n8n → **Settings → Community nodes**
2. **Install a community node**
3. Enter:
```text
n8n-nodes-songs2vid
```
4. Confirm and restart n8n if prompted
5. Search the canvas for **Songs2VID**
Self-hosted n8n needs community packages enabled (`N8N_COMMUNITY_PACKAGES_ENABLED=true`).
### From npm (manual)
```bash
cd ~/.n8n
npm install n8n-nodes-songs2vid
```
Restart n8n.
## Credentials
1. Create an API key under [Dashboard → Settings](https://songs2vid.com/dashboard/settings) (`s2yt_live_…`)
2. Cloud requires **Developer & Automation** (€15) or **Enterprise**, plus YouTube connected
3. In n8n: create a **Songs2VID API** credential
- **API Key:** your token
- **Base URL:** `https://songs2vid.com` (or your self-hosted origin, no trailing slash)
Requests use `Authorization: Bearer …` only.
## Quick start
1. **File → Upload** — cover (`type=image`) → save `path`
2. **File → Upload** — audio (`type=audio`) → save `path` / `filename`
3. **Render → Create Render** — paths + privacy + resolution
Optional: **Additional Fields → Webhook URL** for async completion
4. Handle `job.completed` / `job.item.completed` on an n8n **Webhook** node, or poll **Get Render Status**
## Operations
| Resource | Operation | API |
|----------|-----------|-----|
| Discovery | Get API Catalog | `GET /api/v1` |
| File | Upload | `POST /api/v1/upload` |
| Render | Create Render | `POST /api/v1/render` |
| Render | Create Job (alias) | `POST /api/v1/jobs` |
| Render | Create Batch | `POST /api/v1/jobs/batch` |
| Render | Get Render Status | `GET /api/v1/jobs/:id` |
| Render | List Renders | `GET /api/v1/jobs` |
| Playlist | List / Create | `/api/v1/playlists` |
| API Key | List / Create / Delete | `/api/v1/user/api-keys` |
Layout, watermark, playlist, and multi-track options are under **Additional Fields** on Create Render.
There is no Delete Render endpoint — finished videos live on YouTube.
## Links
- Guide: https://songs2vid.com/docs/n8n
- REST endpoints: https://songs2vid.com/docs/api/endpoints
- Workflow template: https://songs2vid.com/docs/n8n/workflow-template.json
- npm: https://www.npmjs.com/package/n8n-nodes-songs2vid
## Development
```bash
npm install
npm run build
```
`prepublishOnly` runs the build before `npm publish`.
## License
MIT
@@ -0,0 +1,57 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from "n8n-workflow";
/**
* Songs2VID REST auth — cloud accepts Authorization: Bearer only.
* Paste the raw key (s2yt_live_…) or a full "Bearer …" value.
*/
export class Songs2VidApi implements ICredentialType {
name = "songs2VidApi";
displayName = "Songs2VID API";
documentationUrl = "https://songs2vid.com/docs/n8n";
properties: INodeProperties[] = [
{
displayName: "API Key",
name: "apiKey",
type: "string",
typeOptions: { password: true },
default: "",
required: true,
description:
"Developer+ key from Dashboard → Settings (starts with s2yt_live_). Sent as Authorization: Bearer.",
},
{
displayName: "Base URL",
name: "baseUrl",
type: "string",
default: "https://songs2vid.com",
required: true,
description: "Cloud origin or self-hosted base (no trailing slash).",
},
];
authenticate: IAuthenticateGeneric = {
type: "generic",
properties: {
headers: {
Authorization:
'={{ $credentials.apiKey.toString().startsWith("Bearer ") ? $credentials.apiKey : "Bearer " + $credentials.apiKey }}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: "={{ $credentials.baseUrl.replace(/\\/$/, \"\") }}",
url: "/api/v1/user/api-keys",
method: "GET",
},
};
}
+14
View File
@@ -0,0 +1,14 @@
const { src, dest } = require("gulp");
/**
* Copy node/credential icons (+ codex JSON) into dist so n8n can resolve
* `icon: "file:songs2vid.svg"` next to the compiled .js files.
*/
function copyIcons() {
return src(
["nodes/**/*.{png,svg,json}", "credentials/**/*.{png,svg,json}"],
{ base: "." },
).pipe(dest("dist"));
}
exports["build:icons"] = copyIcons;
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
"use strict";
module.exports = {};
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-songs2vid.songs2Vid",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Marketing", "Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://songs2vid.com/docs/n8n"
}
],
"primaryDocumentation": [
{
"url": "https://songs2vid.com/docs/api/endpoints"
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" fill="none">
<rect width="60" height="60" rx="12" fill="#0a0a0a"/>
<text x="8" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#fff">S2</text>
<text x="30" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#f87171">VID</text>
</svg>

After

Width:  |  Height:  |  Size: 355 B

+5242
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "n8n-nodes-songs2vid",
"version": "1.0.0",
"description": "n8n community node for Songs2VID — upload audio/covers, create YouTube renders, poll jobs, manage playlists & API keys",
"keywords": [
"n8n-community-node-package",
"n8n-node",
"songs2vid",
"audio-to-video",
"youtube"
],
"license": "MIT",
"homepage": "https://songs2vid.com/docs/n8n",
"author": {
"name": "Songs2VID",
"url": "https://songs2vid.com"
},
"repository": {
"type": "git",
"url": "https://git.atakanozban.com/Songs2VID/songs2vid.git",
"directory": "integrations/n8n"
},
"bugs": {
"url": "https://songs2vid.com/docs/n8n"
},
"main": "index.js",
"scripts": {
"build": "tsc && gulp build:icons",
"dev": "tsc --watch",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/Songs2VidApi.credentials.js"
],
"nodes": [
"dist/nodes/Songs2Vid/Songs2Vid.node.js"
]
},
"devDependencies": {
"@types/node": "^20.11.0",
"gulp": "^4.0.2",
"n8n-workflow": "^1.70.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"n8n-workflow": "*"
},
"engines": {
"node": ">=18.10"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["ES2019"],
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["credentials/**/*", "nodes/**/*"],
"exclude": ["dist", "node_modules"]
}
@@ -0,0 +1,395 @@
{
"name": "Songs2VID — Audio → Render → YouTube (full)",
"meta": {
"templateCredsSetupCompleted": false,
"instanceId": "songs2vid-docs-template-v2"
},
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
0,
0
]
},
{
"parameters": {
"path": "songs2vid-complete",
"httpMethod": "POST",
"responseMode": "onReceived",
"options": {}
},
"id": "webhook-complete",
"name": "Songs2VID Job Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
360
],
"webhookId": "songs2vid-complete"
},
{
"parameters": {
"content": "## Inputs\nProvide binary fields (`cover`, `audio`) or replace Manual Trigger with Drive/Dropbox.\n\nCredential: Header Auth → Name `Authorization`, Value `Bearer s2yt_live_…`\n\nSet env `SONGS2VID_WEBHOOK_URL` to this workflow's Production Webhook URL.",
"height": 240,
"width": 320
},
"id": "sticky-inputs",
"name": "Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-320,
-40
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "cover"
},
{
"name": "type",
"value": "image"
}
]
},
"options": {}
},
"id": "upload-cover",
"name": "Upload Cover Image",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
280,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "audio"
},
{
"name": "type",
"value": "audio"
}
]
},
"options": {}
},
"id": "upload-audio",
"name": "Upload Audio",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/render",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"imagePath\": \"{{ $('Upload Cover Image').item.json.path }}\",\n \"webhookUrl\": \"{{ $env.SONGS2VID_WEBHOOK_URL || '' }}\",\n \"items\": [{\n \"audioPath\": \"{{ $('Upload Audio').item.json.path }}\",\n \"audioFilename\": \"{{ $('Upload Audio').item.json.filename }}\",\n \"metadata\": {\n \"title\": \"{{ $('Upload Audio').item.json.audioTags?.title || $('Upload Audio').item.json.filename }}\",\n \"songTitle\": \"{{ $('Upload Audio').item.json.audioTags?.title || '' }}\",\n \"artist\": \"{{ $('Upload Audio').item.json.audioTags?.artist || '' }}\",\n \"description\": \"Uploaded via n8n + Songs2VID\",\n \"tags\": \"music,songs2vid\",\n \"privacy\": \"UNLISTED\",\n \"categoryId\": \"10\",\n \"resolution\": \"1920x1080\",\n \"notifySubscribers\": false,\n \"madeForKids\": false,\n \"embeddable\": true,\n \"creativeCommons\": false,\n \"includeWatermark\": false\n }\n }]\n}",
"options": {}
},
"id": "create-render",
"name": "Create Render",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
760,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}{{ $json.statusUrl }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {}
},
"id": "poll-status",
"name": "Get Render Status (optional poll)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1000,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
},
"disabled": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "job-done",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.completed",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "item-done",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.item.completed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "if-completed",
"name": "Render Completed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
280,
360
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "job-fail",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.failed",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "item-fail",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.item.failed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "if-failed",
"name": "Render Failed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
280,
560
]
},
{
"parameters": {
"content": "## YouTube live\nVideo ID: `{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}`\n\nhttps://youtu.be/{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}",
"height": 180,
"width": 360
},
"id": "sticky-success",
"name": "Success",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
320
]
},
{
"parameters": {
"content": "## Failed\n`{{ $json.body?.error || $json.error }}`",
"height": 160,
"width": 360
},
"id": "sticky-fail",
"name": "Failure",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
520
]
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Upload Cover Image",
"type": "main",
"index": 0
}
]
]
},
"Upload Cover Image": {
"main": [
[
{
"node": "Upload Audio",
"type": "main",
"index": 0
}
]
]
},
"Upload Audio": {
"main": [
[
{
"node": "Create Render",
"type": "main",
"index": 0
}
]
]
},
"Create Render": {
"main": [
[
{
"node": "Get Render Status (optional poll)",
"type": "main",
"index": 0
}
]
]
},
"Songs2VID Job Webhook": {
"main": [
[
{
"node": "Render Completed?",
"type": "main",
"index": 0
}
]
]
},
"Render Completed?": {
"main": [
[
{
"node": "Success",
"type": "main",
"index": 0
}
],
[
{
"node": "Render Failed?",
"type": "main",
"index": 0
}
]
]
},
"Render Failed?": {
"main": [
[
{
"node": "Failure",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "songs2vid"
},
{
"name": "n8n"
},
{
"name": "youtube"
}
],
"triggerCount": 0,
"updatedAt": "2026-08-08T00:00:00.000Z",
"versionId": "2"
}
+47 -4
View File
@@ -116,6 +116,38 @@ function classicScaleFilter(width: number, height: number): string {
return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`; return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
} }
/**
* Plan-aware audio for MP4.
* OSS / self-hosted defaults to Pro-quality 320k AAC.
*/
export type AudioEncodeOptions = {
bitrateKbps: 192 | 320;
copyWhenSafe: boolean;
audioPath: string;
};
function isCopySafeAudioPath(audioPath: string): boolean {
const ext = path.extname(audioPath).toLowerCase();
return ext === ".aac" || ext === ".m4a" || ext === ".mp4";
}
function pushAudioEncodeArgs(args: string[], audio: AudioEncodeOptions): void {
if (audio.copyWhenSafe && isCopySafeAudioPath(audio.audioPath)) {
args.push("-c:a", "copy");
return;
}
args.push(
"-c:a",
"aac",
"-b:a",
`${audio.bitrateKbps}k`,
"-ar",
"44100",
"-ac",
"2",
);
}
/** /**
* Art-track filter that ends at `outLabel` instead of hardcoded [laid]. * Art-track filter that ends at `outLabel` instead of hardcoded [laid].
*/ */
@@ -149,10 +181,19 @@ export async function encodeVideo(options: {
* When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp. * When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp.
*/ */
backgroundImagePath?: string | null; backgroundImagePath?: string | null;
/** Defaults to 320k (self-hosted / studio). */
audioBitrateKbps?: 192 | 320;
audioCopyWhenSafe?: boolean;
}): Promise<void> { }): Promise<void> {
const res = getResolution(options.resolution); const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`); if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
const audioEncode: AudioEncodeOptions = {
bitrateKbps: options.audioBitrateKbps ?? 320,
copyWhenSafe: options.audioCopyWhenSafe ?? true,
audioPath: options.audioPath,
};
await fs.mkdir(path.dirname(options.outputPath), { recursive: true }); await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark); const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
@@ -225,8 +266,9 @@ export async function encodeVideo(options: {
"libx264", "libx264",
"-tune", "-tune",
"stillimage", "stillimage",
"-c:a", );
"copy", pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest", "-shortest",
"-pix_fmt", "-pix_fmt",
"yuv420p", "yuv420p",
@@ -321,8 +363,9 @@ export async function encodeVideo(options: {
"libx264", "libx264",
"-tune", "-tune",
"stillimage", "stillimage",
"-c:a", );
"copy", pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest", "-shortest",
"-pix_fmt", "-pix_fmt",
"yuv420p", "yuv420p",
+13
View File
@@ -292,11 +292,24 @@ export async function createVideoJob(
await reserveQuota(user.id, items.length); await reserveQuota(user.id, items.length);
const webhookRaw = body.webhookUrl?.trim() || null;
if (webhookRaw) {
try {
const u = new URL(webhookRaw);
if (u.protocol !== "https:" && u.protocol !== "http:") {
throw new Error("webhookUrl must be http(s)");
}
} catch {
throw new Error("Invalid webhookUrl. Provide an absolute http(s) URL for n8n callbacks.");
}
}
try { try {
const job = await prisma.job.create({ const job = await prisma.job.create({
data: { data: {
userId: user.id, userId: user.id,
imagePath, imagePath,
webhookUrl: webhookRaw,
items: { items: {
create: items.map((item, index) => { create: items.map((item, index) => {
const wm = applyBrandWatermarkPolicy( const wm = applyBrandWatermarkPolicy(
+2
View File
@@ -94,6 +94,8 @@ export type CreateJobPayload = {
audioFilename: string; audioFilename: string;
metadata: ItemMetadata; metadata: ItemMetadata;
}>; }>;
/** Absolute http(s) URL — Songs2VID POSTs JSON on item/job terminal states (n8n). */
webhookUrl?: string | null;
/** Create a new playlist and attach its ID to every item (Pro). */ /** Create a new playlist and attach its ID to every item (Pro). */
createPlaylist?: CreatePlaylistRequest | null; createPlaylist?: CreatePlaylistRequest | null;
}; };
+52
View File
@@ -0,0 +1,52 @@
/**
* Deliver optional job webhooks for n8n / automation consumers.
* Fire-and-forget; failures are logged and never fail the encode/upload path.
*/
type WebhookPayload = {
event: "job.item.completed" | "job.item.failed" | "job.completed" | "job.failed" | "job.partial";
jobId: string;
status: string;
itemId?: string;
youtubeVideoId?: string | null;
error?: string | null;
completedAt?: string | null;
};
export async function deliverJobWebhook(
webhookUrl: string | null | undefined,
payload: WebhookPayload,
): Promise<void> {
if (!webhookUrl?.trim()) return;
let url: URL;
try {
url = new URL(webhookUrl.trim());
} catch {
console.warn(`[webhook] invalid URL for job ${payload.jobId}`);
return;
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
console.warn(`[webhook] refused non-http(s) URL for job ${payload.jobId}`);
return;
}
try {
const res = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Songs2VID-Webhook/1.0",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(8_000),
});
if (!res.ok) {
console.warn(`[webhook] ${payload.jobId}${res.status}`);
}
} catch (err) {
console.warn(
`[webhook] delivery failed for ${payload.jobId}:`,
err instanceof Error ? err.message : err,
);
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Job" ADD COLUMN IF NOT EXISTS "webhookUrl" TEXT;
+2
View File
@@ -59,6 +59,8 @@ model Job {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
status JobStatus @default(PENDING) status JobStatus @default(PENDING)
imagePath String imagePath String
/// Optional absolute http(s) URL for n8n / automation callbacks
webhookUrl String?
items JobItem[] items JobItem[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
completedAt DateTime? completedAt DateTime?
+37 -1
View File
@@ -9,8 +9,13 @@ import { getJobDir } from "../lib/storage";
import type { VideoJobData } from "../lib/types"; import type { VideoJobData } from "../lib/types";
import { formatYouTubeErrorForUser } from "../lib/youtube/errors"; import { formatYouTubeErrorForUser } from "../lib/youtube/errors";
import { uploadToYouTube } from "../lib/youtube/upload"; import { uploadToYouTube } from "../lib/youtube/upload";
import { deliverJobWebhook } from "../lib/webhooks";
async function updateJobStatus(jobId: string) { async function updateJobStatus(jobId: string) {
const job = await prisma.job.findUniqueOrThrow({
where: { id: jobId },
select: { webhookUrl: true },
});
const items = await prisma.jobItem.findMany({ where: { jobId } }); const items = await prisma.jobItem.findMany({ where: { jobId } });
const completed = items.filter((i) => i.status === JobItemStatus.COMPLETED).length; const completed = items.filter((i) => i.status === JobItemStatus.COMPLETED).length;
const failed = items.filter((i) => i.status === JobItemStatus.FAILED).length; const failed = items.filter((i) => i.status === JobItemStatus.FAILED).length;
@@ -23,13 +28,29 @@ async function updateJobStatus(jobId: string) {
else status = JobStatus.PARTIAL; else status = JobStatus.PARTIAL;
} }
const completedAt = completed + failed === total ? new Date() : null;
await prisma.job.update({ await prisma.job.update({
where: { id: jobId }, where: { id: jobId },
data: { data: {
status, status,
completedAt: completed + failed === total ? new Date() : null, completedAt,
}, },
}); });
if (completedAt) {
const event =
status === JobStatus.COMPLETED
? "job.completed"
: status === JobStatus.FAILED
? "job.failed"
: "job.partial";
await deliverJobWebhook(job.webhookUrl, {
event,
jobId,
status,
completedAt: completedAt.toISOString(),
});
}
} }
async function processJobItem(data: VideoJobData) { async function processJobItem(data: VideoJobData) {
@@ -136,6 +157,14 @@ async function processJobItem(data: VideoJobData) {
data: { createdVideoCount: { increment: 1 } }, data: { createdVideoCount: { increment: 1 } },
}); });
await deliverJobWebhook(item.job.webhookUrl, {
event: "job.item.completed",
jobId: data.jobId,
status: "COMPLETED",
itemId: item.id,
youtubeVideoId,
});
await cleanupFiles([outputPath]); await cleanupFiles([outputPath]);
} catch (err) { } catch (err) {
const message = formatYouTubeErrorForUser(err); const message = formatYouTubeErrorForUser(err);
@@ -144,6 +173,13 @@ async function processJobItem(data: VideoJobData) {
where: { id: item.id }, where: { id: item.id },
data: { status: JobItemStatus.FAILED, error: message }, data: { status: JobItemStatus.FAILED, error: message },
}); });
await deliverJobWebhook(item.job.webhookUrl, {
event: "job.item.failed",
jobId: data.jobId,
status: "FAILED",
itemId: item.id,
error: message,
});
throw new Error(message); throw new Error(message);
} finally { } finally {
await updateJobStatus(data.jobId); await updateJobStatus(data.jobId);