/** * 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 { 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, ); } }