9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
550 lines
25 KiB
JavaScript
550 lines
25 KiB
JavaScript
/**
|
|
* Suno web (suno.com) browser automation helpers — rewritten for the
|
|
* /api/generate/v2-web/ schema introduced 2026-05.
|
|
*
|
|
* Auth model: Bearer JWT from Clerk (`window.Clerk.session.getToken()`).
|
|
*
|
|
* The studio backend lives on `studio-api-prod.suno.com`; the page itself is
|
|
* on `suno.com`. The browser's normal cross-origin cookie-bearing fetch
|
|
* succeeds from a real Chrome tab, but the OpenCLI bridge's evaluate
|
|
* context isolates third-party cookies — `credentials: 'include'` drops the
|
|
* Clerk session cookie. Sending the JWT explicitly as `Authorization: Bearer`
|
|
* bypasses the isolation and matches the auth path the studio API expects.
|
|
*
|
|
* Required custom headers (in addition to Bearer):
|
|
* - browser-token: {"token":"<base64 of {timestamp: ms}>"} (anti-replay)
|
|
* - device-id: <uuid> (persistent per browser, found in `suno_device_id` cookie)
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors';
|
|
|
|
export const SUNO_DOMAIN = 'suno.com';
|
|
export const SUNO_URL = 'https://suno.com';
|
|
export const STUDIO_API = 'https://studio-api-prod.suno.com';
|
|
export const SUNO_CDN = 'https://cdn1.suno.ai';
|
|
|
|
// As of 2026-05, the UI exposes V5.5 (chirp-fenix) and V4.5+ (chirp-bluejay).
|
|
// Older versions are still routable via the API and remain valid `mv` values.
|
|
export const SUNO_MODELS = ['chirp-fenix', 'chirp-bluejay', 'chirp-v4', 'chirp-v3-5'];
|
|
export const DEFAULT_SUNO_MODEL = 'chirp-fenix';
|
|
|
|
export const SUPPORTED_FORMATS = ['mp3', 'm4a', 'wav', 'video', 'cover', 'metadata'];
|
|
export const DEFAULT_FORMATS = ['mp3', 'metadata'];
|
|
|
|
export function parseFormats(value) {
|
|
if (value === undefined || value === null || value === '') return DEFAULT_FORMATS.slice();
|
|
const raw = Array.isArray(value) ? value.join(',') : String(value);
|
|
const parts = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
if (!parts.length) return DEFAULT_FORMATS.slice();
|
|
const unknown = parts.filter(p => !SUPPORTED_FORMATS.includes(p));
|
|
if (unknown.length) {
|
|
throw new ArgumentError(
|
|
`Unsupported --formats value(s): ${unknown.join(', ')}`,
|
|
`Supported: ${SUPPORTED_FORMATS.join(', ')}. (stems require multi-step extraction and are not yet wired.)`,
|
|
);
|
|
}
|
|
return Array.from(new Set(parts));
|
|
}
|
|
|
|
export function resolveSunoOutputDir(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) return path.join(process.env.HOME || '~', 'Music', 'suno');
|
|
if (raw === '~') return process.env.HOME || '~';
|
|
if (raw.startsWith('~/')) return path.join(process.env.HOME || '~', raw.slice(2));
|
|
return path.resolve(raw);
|
|
}
|
|
|
|
export function sanitizeTitleForFilename(title, fallback = 'untitled') {
|
|
const cleaned = String(title || fallback)
|
|
.replace(/[\\/:*?"<>|\x00-\x1f]+/g, '-')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 60);
|
|
return cleaned || fallback;
|
|
}
|
|
|
|
export function ensureDir(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
export function normalizeBooleanFlag(value, fallback = false) {
|
|
if (typeof value === 'boolean') return value;
|
|
if (value === null || value === undefined || value === '') return fallback;
|
|
const s = String(value).trim().toLowerCase();
|
|
return s === 'true' || s === '1' || s === 'yes' || s === 'on';
|
|
}
|
|
|
|
export function unwrapEvaluateResult(value) {
|
|
if (value && typeof value === 'object' && 'session' in value && 'data' in value) {
|
|
return value.data;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function requirePositiveInt(value, label) {
|
|
const n = Number(value);
|
|
if (!Number.isInteger(n) || n < 1) {
|
|
throw new ArgumentError(`${label} must be a positive integer`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
export function requireNonNegativeInt(value, label) {
|
|
const n = Number(value);
|
|
if (!Number.isInteger(n) || n < 0) {
|
|
throw new ArgumentError(`${label} must be a non-negative integer`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
export function clampSlider(value, label, def) {
|
|
if (value === undefined || value === null || value === '') return def;
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
throw new ArgumentError(`${label} must be a number between 0 and 1`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// In-page helper snippets. Each is inlined into a page.evaluate() call so
|
|
// the browser-token is generated fresh per request and the Clerk token is
|
|
// pulled live (avoiding 60s TTL expiry on long polls).
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const BROWSER_TOKEN_JS = `JSON.stringify({ token: btoa(JSON.stringify({ timestamp: Date.now() })) })`;
|
|
const CLERK_TOKEN_JS = `await window.Clerk.session.getToken()`;
|
|
|
|
/**
|
|
* Build the standard header set used by every studio-api-prod.suno.com call.
|
|
*
|
|
* deviceId is read once per command and embedded literally; browser-token
|
|
* and Authorization are computed inline (timestamp/JWT refresh per call).
|
|
*/
|
|
function sunoHeadersJs(deviceId, extra = {}) {
|
|
const extraEntries = Object.entries(extra).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ');
|
|
return `{
|
|
'Authorization': 'Bearer ' + ${CLERK_TOKEN_JS},
|
|
'browser-token': ${BROWSER_TOKEN_JS},
|
|
'device-id': ${JSON.stringify(deviceId)},
|
|
${extraEntries}${extraEntries ? ',' : ''}
|
|
}`;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Session bootstrap.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse studio-api-prod billing/info. Inlined into the page IIFE via
|
|
* toString() so the same parser runs in Node tests and the browser.
|
|
*/
|
|
export function parseSunoBillingInfo(data) {
|
|
const packCredits = (data?.credit_packs || []).reduce((s, p) => s + (p?.amount ?? p?.credits ?? 0), 0);
|
|
const monthlyRemaining = Math.max(0, (data?.monthly_limit ?? 0) - (data?.monthly_usage ?? 0));
|
|
const totalCreditsAvailable = typeof data?.total_credits_left === 'number'
|
|
? data.total_credits_left
|
|
: (data?.credits ?? 0) + packCredits + monthlyRemaining;
|
|
const plans = Array.isArray(data?.plans) ? data.plans : [];
|
|
const subscriptionKey = typeof data?.subscription_type === 'string' && data.subscription_type
|
|
? data.subscription_type
|
|
: null;
|
|
const currentPlan = subscriptionKey
|
|
? plans.find((p) => p?.plan_key === subscriptionKey)
|
|
: plans.find((p) => p?.plan_key === 'free');
|
|
return {
|
|
planId: currentPlan?.id || data?.plan?.id || null,
|
|
planKey: currentPlan?.plan_key || data?.plan?.plan_key || (subscriptionKey ?? 'free'),
|
|
totalCreditsAvailable,
|
|
breakdown: {
|
|
pack: data?.credits ?? 0,
|
|
purchasedPacks: packCredits,
|
|
monthlyRemaining,
|
|
monthlyLimit: data?.monthly_limit ?? 0,
|
|
monthlyUsed: data?.monthly_usage ?? 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function ensureSunoSession(page) {
|
|
await page.goto(`${SUNO_URL}/me`, { settleMs: 2000 });
|
|
// OneTrust consent banner can block the page; dismiss it if present.
|
|
await page.evaluate(`(() => {
|
|
const btn = Array.from(document.querySelectorAll('button')).find(b => /^reject all$|^accept all$|^confirm/i.test((b.innerText || '').trim()));
|
|
if (btn) btn.click();
|
|
})()`);
|
|
|
|
// Wait briefly for Clerk to mount before any API call.
|
|
for (let i = 0; i < 20; i += 1) {
|
|
const ready = unwrapEvaluateResult(await page.evaluate(`!!(window.Clerk && window.Clerk.session)`));
|
|
if (ready) break;
|
|
await page.wait(0.5);
|
|
}
|
|
|
|
const deviceId = await getSunoDeviceId(page);
|
|
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
try {
|
|
if (!window.Clerk?.session) return { ok: false, auth: true, error: 'Clerk session unavailable' };
|
|
const res = await fetch('${STUDIO_API}/api/billing/info/', { headers: ${sunoHeadersJs(deviceId)} });
|
|
if (!res.ok) return { ok: false, status: res.status, body: (await res.text()).slice(0, 300) };
|
|
let data = null;
|
|
try {
|
|
data = await res.json();
|
|
} catch (e) {
|
|
return { ok: false, error: 'Malformed billing/info JSON: ' + String(e).slice(0, 200) };
|
|
}
|
|
const parse = ${parseSunoBillingInfo.toString()};
|
|
return { ok: true, ...parse(data) };
|
|
} catch (e) {
|
|
return { ok: false, error: String(e).slice(0, 200) };
|
|
}
|
|
})()`));
|
|
|
|
if (!result || !result.ok) {
|
|
const detail = result?.status || result?.error || 'unknown';
|
|
if (result?.auth || result?.status === 401 || result?.status === 403) {
|
|
throw new AuthRequiredError(SUNO_DOMAIN, `Suno session check failed (${detail}). Open https://suno.com in Chrome and sign in, then retry.`);
|
|
}
|
|
throw new CommandExecutionError(`Suno session check failed (${detail}).`);
|
|
}
|
|
return { ...result, deviceId };
|
|
}
|
|
|
|
/**
|
|
* Verify the captcha pre-flight. If `required:true`, the simple flow won't
|
|
* work without solving a CAPTCHA (out of scope for the headless adapter).
|
|
*/
|
|
export async function checkSunoCaptcha(page, deviceId) {
|
|
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
const res = await fetch('${STUDIO_API}/api/c/check', {
|
|
method: 'POST',
|
|
headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
|
|
body: JSON.stringify({ ctype: 'generation' }),
|
|
});
|
|
if (!res.ok) return { ok: false, status: res.status };
|
|
return { ok: true, ...(await res.json()) };
|
|
})()`));
|
|
return result; // { ok, required, captcha_version }
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// device-id discovery. Suno frontend writes `suno_device_id` as a cookie on
|
|
// first load; we re-use it so every command shares the same identity.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export async function getSunoDeviceId(page) {
|
|
const id = unwrapEvaluateResult(await page.evaluate(`(() => {
|
|
try {
|
|
const fromCookie = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('suno_device_id='));
|
|
if (fromCookie) return decodeURIComponent(fromCookie.split('=')[1]);
|
|
for (const key of ['device_id', 'deviceId', 'suno_device_id']) {
|
|
const v = localStorage.getItem(key);
|
|
if (v) return v.replace(/^"|"$/g, '');
|
|
}
|
|
} catch {}
|
|
return null;
|
|
})()`));
|
|
if (id && typeof id === 'string') return id;
|
|
return unwrapEvaluateResult(await page.evaluate(`crypto.randomUUID()`));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Generate (Custom or Simple mode).
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Submit a generation request via /api/generate/v2-web/.
|
|
*
|
|
* payload fields:
|
|
* - mode: 'custom' | 'simple'
|
|
* - model: chirp-fenix etc.
|
|
* - title: song title (required by API even for Simple mode)
|
|
* - lyrics: Custom-mode lyrics (with [Verse] metatags). Used as API `prompt`.
|
|
* - tags: Custom-mode style string.
|
|
* - negativeTags: Custom-mode exclusion tags.
|
|
* - description: Simple-mode description string. Sent in `prompt` per the
|
|
* v2-web schema (the older `gpt_description_prompt` field is gone).
|
|
* - makeInstrumental: bool
|
|
* - weirdness, styleWeight: 0..1 sliders
|
|
* - userTier: UUID from billing/info plan.id
|
|
* - createSessionToken: UUID, generated per session
|
|
* - deviceId: UUID from local browser
|
|
*/
|
|
export async function submitSunoGeneration(page, payload) {
|
|
const isCustom = payload.mode === 'custom';
|
|
const body = {
|
|
token: null,
|
|
generation_type: 'TEXT',
|
|
title: payload.title || '',
|
|
tags: isCustom ? (payload.tags || '') : '',
|
|
negative_tags: isCustom ? (payload.negativeTags || '') : '',
|
|
mv: payload.model || DEFAULT_SUNO_MODEL,
|
|
prompt: isCustom ? (payload.lyrics || '') : (payload.description || ''),
|
|
make_instrumental: !!payload.makeInstrumental,
|
|
user_uploaded_images_b64: null,
|
|
metadata: {
|
|
web_client_pathname: '/create',
|
|
is_max_mode: false,
|
|
is_mumble: false,
|
|
create_mode: isCustom ? 'custom' : 'simple',
|
|
user_tier: payload.userTier,
|
|
create_session_token: payload.createSessionToken,
|
|
disable_volume_normalization: false,
|
|
control_sliders: {
|
|
weirdness_constraint: payload.weirdness,
|
|
style_weight: payload.styleWeight,
|
|
},
|
|
},
|
|
override_fields: [],
|
|
cover_clip_id: null,
|
|
cover_start_s: null,
|
|
cover_end_s: null,
|
|
persona_id: null,
|
|
artist_clip_id: null,
|
|
artist_start_s: null,
|
|
artist_end_s: null,
|
|
continue_clip_id: null,
|
|
continued_aligned_prompt: null,
|
|
continue_at: null,
|
|
transaction_uuid: payload.transactionUuid,
|
|
token_provider: null,
|
|
};
|
|
|
|
const bodyJson = JSON.stringify(body);
|
|
const deviceId = payload.deviceId;
|
|
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
const res = await fetch('${STUDIO_API}/api/generate/v2-web/', {
|
|
method: 'POST',
|
|
headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
|
|
body: ${JSON.stringify(bodyJson)},
|
|
});
|
|
const text = await res.text();
|
|
let parsed = null;
|
|
try { parsed = JSON.parse(text); } catch {}
|
|
return { status: res.status, ok: res.ok, body: parsed, raw: parsed ? null : text.slice(0, 600) };
|
|
})()`));
|
|
|
|
if (!result || !result.ok) {
|
|
const status = result?.status || 'unknown';
|
|
const detail = result?.body?.detail || result?.raw || JSON.stringify(result?.body || {}).slice(0, 500);
|
|
if (status === 401 || status === 403) {
|
|
throw new AuthRequiredError(SUNO_DOMAIN, `Suno API rejected request (HTTP ${status}). Re-login on suno.com.`);
|
|
}
|
|
if (status === 402) {
|
|
throw new CommandExecutionError(`Suno API: insufficient credits (HTTP 402). ${detail}`);
|
|
}
|
|
throw new CommandExecutionError(`Suno generate failed (HTTP ${status}): ${detail}`);
|
|
}
|
|
|
|
if (!result.body || typeof result.body !== 'object' || Array.isArray(result.body)) {
|
|
throw new CommandExecutionError('Suno generate returned malformed JSON payload.');
|
|
}
|
|
const clips = result.body?.clips || [];
|
|
if (!clips.length) {
|
|
throw new EmptyResultError('suno generate', `Submission accepted but Suno returned no clip ids. Raw: ${JSON.stringify(result.body).slice(0, 300)}`);
|
|
}
|
|
return result.body;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Poll /api/feed/v3 (cookie auth, no Bearer).
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export async function pollSunoClips(page, clipIds, timeoutSeconds, deviceId, pollSeconds = 5, onProgress = null) {
|
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
const targetSet = new Set(clipIds);
|
|
const idsJson = JSON.stringify(clipIds);
|
|
|
|
while (Date.now() < deadline) {
|
|
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
const res = await fetch('${STUDIO_API}/api/feed/v3', {
|
|
method: 'POST',
|
|
headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
|
|
body: JSON.stringify({ clip_ids: ${idsJson} }),
|
|
});
|
|
const body = await res.json().catch(() => null);
|
|
return { status: res.status, body };
|
|
})()`));
|
|
|
|
if (!result) {
|
|
await page.wait(pollSeconds);
|
|
continue;
|
|
}
|
|
if (result.status === 401 || result.status === 403) {
|
|
throw new AuthRequiredError(SUNO_DOMAIN, `Suno feed API rejected (HTTP ${result.status}). Re-login.`);
|
|
}
|
|
if (result.status < 200 || result.status >= 300) {
|
|
throw new CommandExecutionError(`Suno feed API failed while polling clips (HTTP ${result.status || '?'})`);
|
|
}
|
|
if (!result.body || typeof result.body !== 'object' || Array.isArray(result.body)) {
|
|
throw new CommandExecutionError('Suno feed API returned malformed JSON while polling clips');
|
|
}
|
|
|
|
const allClips = result.body.clips || [];
|
|
if (!Array.isArray(allClips)) {
|
|
throw new CommandExecutionError('Suno feed API returned malformed clips payload');
|
|
}
|
|
const ourClips = allClips.filter(c => targetSet.has(c.id));
|
|
const finished = ourClips.filter(c => c.status === 'complete' || c.status === 'error');
|
|
|
|
if (typeof onProgress === 'function') {
|
|
onProgress({ total: clipIds.length, done: finished.length, statuses: ourClips.map(c => `${c.id.slice(0,8)}:${c.status}`) });
|
|
}
|
|
|
|
if (finished.length === clipIds.length) return ourClips;
|
|
await page.wait(pollSeconds);
|
|
}
|
|
|
|
throw new TimeoutError(`Suno generation did not complete within ${timeoutSeconds}s. Try --timeout <higher>.`);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Asset download.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function pickMediaUrl(clip, contentTypeFragment) {
|
|
const arr = Array.isArray(clip.media_urls) ? clip.media_urls : [];
|
|
const hit = arr.find(m => (m.content_type || '').toLowerCase().includes(contentTypeFragment));
|
|
return hit?.url || null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the canonical MP3 URL for a clip. Suno's /api/download/clip/{id}
|
|
* returns a fresh signed URL; clip.audio_url is a backup.
|
|
*/
|
|
async function resolveMp3Url(page, clip, deviceId) {
|
|
const fromApi = await page.evaluate(`(async () => {
|
|
try {
|
|
const res = await fetch('${STUDIO_API}/api/download/clip/${clip.id}?format=mp3', { headers: ${sunoHeadersJs(deviceId)} });
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
return data?.download_url || null;
|
|
} catch { return null; }
|
|
})()`);
|
|
return fromApi || clip.audio_url || `${SUNO_CDN}/${clip.id}.mp3`;
|
|
}
|
|
|
|
/**
|
|
* Trigger WAV conversion (if not already converted), then poll for the file
|
|
* URL. Suno charges a download credit for WAV — the billing call is required.
|
|
*/
|
|
async function ensureSunoWav(page, clipId, deviceId, timeoutSeconds = 120, pollSeconds = 3) {
|
|
// Charge the credit + queue conversion. Both calls fire idempotently if
|
|
// the wav is already cached server-side.
|
|
await page.evaluate(`(async () => {
|
|
try {
|
|
await fetch('${STUDIO_API}/api/billing/clips/${clipId}/download/', {
|
|
method: 'POST',
|
|
headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
|
|
body: JSON.stringify({}),
|
|
});
|
|
} catch {}
|
|
try {
|
|
await fetch('${STUDIO_API}/api/gen/${clipId}/convert_wav/', {
|
|
method: 'POST',
|
|
headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
|
|
});
|
|
} catch {}
|
|
})()`);
|
|
|
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
while (Date.now() < deadline) {
|
|
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
const res = await fetch('${STUDIO_API}/api/gen/${clipId}/wav_file/', { headers: ${sunoHeadersJs(deviceId)} });
|
|
if (!res.ok) return { ok: false, status: res.status };
|
|
const data = await res.json().catch(() => null);
|
|
return { ok: true, url: data?.wav_file_url || null };
|
|
})()`));
|
|
if (result?.ok && result.url) return result.url;
|
|
await page.wait(pollSeconds);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function downloadBinary(page, url) {
|
|
// CDN URLs (cdn1.suno.ai, cloudfront) are public and reject the CORS
|
|
// preflight triggered by `credentials: 'include'`. Plain fetch is what
|
|
// the Suno web UI uses for asset downloads too.
|
|
return unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
try {
|
|
const res = await fetch(${JSON.stringify(url)});
|
|
if (!res.ok) return { ok: false, status: res.status };
|
|
const blob = await res.blob();
|
|
const dataUrl = await new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(String(reader.result || ''));
|
|
reader.onerror = () => reject(new Error('reader failed'));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
return { ok: true, mime: blob.type || '', dataUrl };
|
|
} catch (e) {
|
|
return { ok: false, error: String(e).slice(0, 200) };
|
|
}
|
|
})()`));
|
|
}
|
|
|
|
async function writeFromDataUrl(page, url, filePath) {
|
|
const result = await downloadBinary(page, url);
|
|
if (!result?.ok) {
|
|
return { ok: false, reason: result?.status ? `HTTP ${result.status}` : (result?.error || 'unknown') };
|
|
}
|
|
const base64 = String(result.dataUrl || '').replace(/^data:[^;]+;base64,/, '');
|
|
if (!base64) {
|
|
return { ok: false, reason: 'empty response body' };
|
|
}
|
|
fs.writeFileSync(filePath, Buffer.from(base64, 'base64'));
|
|
if (!fs.statSync(filePath).size) {
|
|
return { ok: false, reason: 'empty file written' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function downloadSunoClip(page, clip, outputDir, formats, deviceId) {
|
|
ensureDir(outputDir);
|
|
const slug = `${sanitizeTitleForFilename(clip.title || clip.id)}_${clip.id.slice(0, 8)}`;
|
|
const written = [];
|
|
|
|
if (formats.includes('metadata')) {
|
|
const metaPath = path.join(outputDir, `${slug}.json`);
|
|
fs.writeFileSync(metaPath, JSON.stringify(clip, null, 2), 'utf8');
|
|
written.push({ format: 'metadata', file: metaPath, ok: true });
|
|
}
|
|
|
|
for (const fmt of formats) {
|
|
if (fmt === 'metadata') continue;
|
|
let url = null;
|
|
let ext = '';
|
|
try {
|
|
if (fmt === 'mp3') {
|
|
ext = '.mp3';
|
|
url = await resolveMp3Url(page, clip, deviceId);
|
|
} else if (fmt === 'm4a') {
|
|
ext = '.m4a';
|
|
url = pickMediaUrl(clip, 'm4a');
|
|
} else if (fmt === 'video') {
|
|
ext = '.mp4';
|
|
url = clip.video_url || null;
|
|
} else if (fmt === 'cover') {
|
|
ext = '.jpeg';
|
|
url = clip.image_large_url || clip.image_url || null;
|
|
} else if (fmt === 'wav') {
|
|
ext = '.wav';
|
|
url = await ensureSunoWav(page, clip.id, deviceId);
|
|
}
|
|
} catch (err) {
|
|
written.push({ format: fmt, file: null, ok: false, reason: `prep failed: ${String(err).slice(0, 100)}` });
|
|
continue;
|
|
}
|
|
if (!url) {
|
|
written.push({ format: fmt, file: null, ok: false, reason: 'not available' });
|
|
continue;
|
|
}
|
|
const filePath = path.join(outputDir, `${slug}${ext}`);
|
|
const result = await writeFromDataUrl(page, url, filePath);
|
|
written.push({ format: fmt, file: filePath, ok: result.ok, reason: result.ok ? null : result.reason });
|
|
}
|
|
|
|
return { slug, written };
|
|
}
|