/** useapi.net Mureka v1: async:true, jobs/{jobid}, response.songs/mp3_url. */ import { VclawError } from '../errors.js'; const BASE = 'https://api.useapi.net/v1/mureka'; type Json = Record; export interface MurekaOptions { env?: NodeJS.ProcessEnv; fetcher?: typeof fetch; errorCode?: 'music_gen_failed' | 'tts_failed'; onSubmitted?: (jobid: string) => Promise; } export function murekaObject(value: unknown): Json { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Json : {}; } export function murekaError(options: MurekaOptions, message: string, details: Json = {}): VclawError { return new VclawError(options.errorCode ?? 'music_gen_failed', message, { provider: 'mureka', ...details }); } export function murekaAccount(options: MurekaOptions): Json { const account = (options.env ?? process.env).VCLAW_MUREKA_ACCOUNT?.trim(); return account ? { account } : {}; } /** No automatic POST retries: a lost response must never duplicate paid work. */ export async function murekaRequest(path: string, options: MurekaOptions, body?: Json): Promise { const token = (options.env ?? process.env).USEAPI_API_TOKEN?.trim(); if (!token) throw murekaError(options, 'Mureka requires USEAPI_API_TOKEN and a linked Mureka account.'); let response: Response; try { response = await (options.fetcher ?? fetch)(`${BASE}/${path}`, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(60_000), }); } catch { throw murekaError(options, `Mureka ${body ? 'submission' : 'request'} could not be confirmed. Check jobs before submitting again.`, { endpoint: path }); } if (!response.ok) { // Do not echo provider bodies: account endpoints can contain credentials. if (response.status === 404 && path === 'accounts') { throw murekaError(options, 'No Mureka account is linked. Complete Add account at https://useapi.net/docs/start-here/setup-mureka.', { httpStatus: 404 }); } throw murekaError(options, `Mureka returned HTTP ${response.status}. Check your useapi subscription and linked Mureka account.`, { httpStatus: response.status, endpoint: path }); } try { return murekaObject(await response.json()); } catch { throw murekaError(options, 'Mureka returned invalid JSON. Check jobs before submitting again.', { endpoint: path }); } } function pollSetting(options: MurekaOptions, key: string, fallback: number, minimum: number): number { const raw = (options.env ?? process.env)[key]; if (raw === undefined) return fallback; const value = Number(raw); if (!raw.trim() || !Number.isSafeInteger(value) || value < minimum) { throw murekaError(options, `${key} must be an integer of at least ${minimum}.`); } return value; } /** Bounded polling; expose jobid on failure/timeout so callers can recover. */ export async function generateMureka(path: string, body: Json, options: MurekaOptions): Promise<{ jobid: string; result: Json }> { const attempts = pollSetting(options, 'VCLAW_MUREKA_POLL_MAX_ATTEMPTS', 120, 1); const interval = pollSetting(options, 'VCLAW_MUREKA_POLL_INTERVAL_MS', 3000, 0); let job = await murekaRequest(path, options, { ...murekaAccount(options), ...body, async: true }); const jobid = typeof job.jobid === 'string' ? job.jobid : ''; if (!jobid) throw murekaError(options, 'Mureka did not return a job ID. Check jobs before submitting again.'); await options.onSubmitted?.(jobid); for (let attempt = 0; attempt <= attempts; attempt++) { if (job.status === 'failed') throw murekaError(options, 'Mureka generation failed.', { jobid }); if (job.status === 'completed') return { jobid, result: murekaObject(job.response) }; // A synchronous completion is also documented, even when async was requested. if (job.state === 3) return { jobid, result: job }; if (job.status !== 'created' && job.status !== 'processing' && job.status !== 'pending') { throw murekaError(options, 'Mureka returned an unrecognised job status.', { jobid }); } if (attempt === attempts) break; if (attempt > 0 && interval > 0) await new Promise(resolve => setTimeout(resolve, interval)); try { job = await murekaRequest(`jobs/${encodeURIComponent(jobid)}`, options); } catch { throw murekaError(options, 'Mureka job polling failed. Check this job before generating again.', { jobid }); } } throw murekaError(options, 'Mureka job is still pending. Check this job before generating again.', { jobid }); } /** Media downloads never receive the useapi bearer token. */ export async function downloadMurekaAudio(url: unknown, options: MurekaOptions): Promise { if (typeof url !== 'string' || !URL.canParse(url) || new URL(url).protocol !== 'https:') { throw murekaError(options, 'Mureka did not return an HTTPS audio URL.'); } let response: Response; try { response = await (options.fetcher ?? fetch)(url, { signal: AbortSignal.timeout(60_000) }); } catch { throw murekaError(options, 'Mureka audio download failed.'); } if (!response.ok) throw murekaError(options, `Mureka audio download returned HTTP ${response.status}.`); const contentType = response.headers.get('content-type') ?? ''; if (/json|text\/|html/i.test(contentType)) throw murekaError(options, 'Mureka returned a document instead of audio.'); const bytes = Buffer.from(await response.arrayBuffer()); if (!bytes.length) throw murekaError(options, 'Mureka returned empty audio.'); return bytes; }