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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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/).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 |
Generated
+5242
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user