2075 lines
78 KiB
JavaScript
2075 lines
78 KiB
JavaScript
// ==UserScript==
|
|
// @name ChatGPT to Suno Prompt Copier
|
|
// @namespace local.suno-helper
|
|
// @version 0.9.4
|
|
// @description Copy structured ChatGPT song prompt versions into Suno fields.
|
|
// @author local
|
|
// @match https://chatgpt.com/*
|
|
// @match https://chat.openai.com/*
|
|
// @match https://suno.com/*
|
|
// @match https://*.suno.com/*
|
|
// @updateURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js
|
|
// @downloadURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js
|
|
// @connect *.suno.ai
|
|
// @connect cdn1.suno.ai
|
|
// @connect cdn2.suno.ai
|
|
// @connect *.suno.com
|
|
// @connect studio-api-prod.suno.com
|
|
// @connect 127.0.0.1
|
|
// @connect localhost
|
|
// @run-at document-start
|
|
// @grant GM_addStyle
|
|
// @grant GM_getValue
|
|
// @grant GM_download
|
|
// @grant GM_xmlhttpRequest
|
|
// @grant GM_setClipboard
|
|
// @grant GM_setValue
|
|
// @grant unsafeWindow
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
const SCRIPT_VERSION = "0.9.4";
|
|
const STORAGE_KEY = "chatgptSunoPromptSlots";
|
|
const SLOT_COUNT = 2;
|
|
const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com");
|
|
const isSuno = location.hostname === "suno.com" || location.hostname.endsWith(".suno.com");
|
|
const capturedSunoResponses = [];
|
|
let chatRenderTimer = 0;
|
|
|
|
const FIELD_DEFS = [
|
|
{ key: "title", label: "Title", aliases: ["Title", "제목"] },
|
|
{ key: "style", label: "Style of Music", aliases: ["Style of Music", "Style", "장르", "음악 스타일"] },
|
|
{ key: "lyrics", label: "Lyrics", aliases: ["Lyrics", "가사"] },
|
|
{ key: "exclude", label: "Exclude", aliases: ["Exclude", "Negative Prompt", "제외", "제외 프롬프트"] },
|
|
{ key: "extend", label: "Extend Prompt", aliases: ["Extend용 추가 프롬프트", "Extend", "추가 프롬프트"] },
|
|
];
|
|
|
|
function rememberSunoResponse(url, data) {
|
|
if (!isSuno || !data) return;
|
|
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
if (!/clip|audio|image_url|song|playlist|metadata|cdn[0-9]*\.suno\.ai|download/i.test(text)) return;
|
|
capturedSunoResponses.push({
|
|
url: String(url || location.href),
|
|
data,
|
|
capturedAt: new Date().toISOString(),
|
|
});
|
|
if (capturedSunoResponses.length > 60) capturedSunoResponses.shift();
|
|
}
|
|
|
|
function tryParseJson(text) {
|
|
if (!text || typeof text !== "string") return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function installSunoNetworkCapture() {
|
|
if (!isSuno) return;
|
|
const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
|
|
if (page.__cgsNetworkCaptureInstalled) return;
|
|
page.__cgsNetworkCaptureInstalled = true;
|
|
|
|
const originalFetch = page.fetch;
|
|
if (typeof originalFetch === "function") {
|
|
page.fetch = async function (...args) {
|
|
const response = await originalFetch.apply(this, args);
|
|
const url = args[0]?.url || args[0] || response.url || "";
|
|
if (/suno/i.test(String(url))) {
|
|
response.clone().text().then((text) => {
|
|
rememberSunoResponse(url, tryParseJson(text) || text);
|
|
}).catch(() => {});
|
|
}
|
|
return response;
|
|
};
|
|
}
|
|
|
|
const OriginalXHR = page.XMLHttpRequest;
|
|
if (typeof OriginalXHR === "function") {
|
|
page.XMLHttpRequest = function () {
|
|
const xhr = new OriginalXHR();
|
|
let requestUrl = "";
|
|
const open = xhr.open;
|
|
xhr.open = function (method, url, ...rest) {
|
|
requestUrl = String(url || "");
|
|
return open.call(xhr, method, url, ...rest);
|
|
};
|
|
xhr.addEventListener("load", () => {
|
|
if (!/suno/i.test(requestUrl || xhr.responseURL || "")) return;
|
|
const responseText = typeof xhr.responseText === "string" ? xhr.responseText : "";
|
|
rememberSunoResponse(requestUrl || xhr.responseURL, tryParseJson(responseText) || responseText);
|
|
});
|
|
return xhr;
|
|
};
|
|
}
|
|
}
|
|
|
|
installSunoNetworkCapture();
|
|
|
|
GM_addStyle(`
|
|
#cgs-panel {
|
|
position: fixed;
|
|
right: 16px;
|
|
bottom: 16px;
|
|
z-index: 2147483647;
|
|
width: min(560px, calc(100vw - 32px));
|
|
max-height: min(86vh, 860px);
|
|
color: #f5f7fb;
|
|
background: rgba(16, 18, 24, 0.94);
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
border-radius: 10px;
|
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.3);
|
|
font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
overflow: hidden;
|
|
}
|
|
#cgs-panel.cgs-suno {
|
|
width: min(620px, calc(100vw - 32px));
|
|
}
|
|
#cgs-panel.cgs-chatgpt {
|
|
right: 18px;
|
|
top: 72px;
|
|
bottom: auto;
|
|
border-color: rgba(47, 124, 246, 0.55);
|
|
}
|
|
#cgs-panel.cgs-error {
|
|
border-color: rgba(255, 84, 84, 0.7);
|
|
}
|
|
#cgs-panel * { box-sizing: border-box; }
|
|
#cgs-panel.cgs-minimized .cgs-body { display: none; }
|
|
.cgs-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
padding: 10px 12px;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
font-weight: 700;
|
|
}
|
|
.cgs-body {
|
|
padding: 10px 12px 12px;
|
|
max-height: calc(min(86vh, 860px) - 48px);
|
|
overflow: auto;
|
|
}
|
|
.cgs-row {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
margin-top: 8px;
|
|
}
|
|
.cgs-version {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: #dce3ef;
|
|
}
|
|
.cgs-muted { color: #aeb7c6; font-size: 12px; }
|
|
.cgs-btn {
|
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
background: rgba(255, 255, 255, 0.09);
|
|
color: #f5f7fb;
|
|
border-radius: 7px;
|
|
min-height: 30px;
|
|
padding: 5px 8px;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
}
|
|
.cgs-btn:hover { background: rgba(255, 255, 255, 0.16); }
|
|
.cgs-primary { background: #2f7cf6; border-color: #2f7cf6; }
|
|
.cgs-primary:hover { background: #438bff; }
|
|
.cgs-wide { width: 100%; justify-content: center; }
|
|
.cgs-status {
|
|
min-height: 18px;
|
|
margin-top: 9px;
|
|
color: #b7c9ff;
|
|
font-size: 12px;
|
|
}
|
|
.cgs-field-list {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
}
|
|
.cgs-select {
|
|
width: 100%;
|
|
min-height: 30px;
|
|
margin-top: 8px;
|
|
color: #f5f7fb;
|
|
background: rgba(255, 255, 255, 0.09);
|
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
border-radius: 7px;
|
|
padding: 4px 7px;
|
|
font: inherit;
|
|
}
|
|
.cgs-track-list {
|
|
margin-top: 8px;
|
|
max-height: 300px;
|
|
overflow: auto;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
border-radius: 8px;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
}
|
|
.cgs-track-item {
|
|
display: grid;
|
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
|
gap: 8px;
|
|
align-items: center;
|
|
min-height: 38px;
|
|
padding: 7px 8px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
|
cursor: default;
|
|
user-select: none;
|
|
}
|
|
.cgs-track-item:last-child { border-bottom: 0; }
|
|
.cgs-track-item:hover { background: rgba(255, 255, 255, 0.08); }
|
|
.cgs-track-item.cgs-selected {
|
|
background: rgba(47, 124, 246, 0.28);
|
|
outline: 1px solid rgba(91, 154, 255, 0.55);
|
|
outline-offset: -1px;
|
|
}
|
|
.cgs-track-index { color: #93a4bd; text-align: right; font-variant-numeric: tabular-nums; }
|
|
.cgs-track-title {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: #f5f7fb;
|
|
font-weight: 650;
|
|
}
|
|
.cgs-track-meta { color: #94a3b8; font-size: 11px; }
|
|
.cgs-track-badge {
|
|
color: #b7c9ff;
|
|
font-size: 11px;
|
|
white-space: nowrap;
|
|
}
|
|
.cgs-progress-wrap {
|
|
margin-top: 8px;
|
|
display: grid;
|
|
gap: 4px;
|
|
}
|
|
.cgs-progress-label {
|
|
color: #b7c9ff;
|
|
font-size: 12px;
|
|
}
|
|
.cgs-progress {
|
|
height: 10px;
|
|
overflow: hidden;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.12);
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
}
|
|
.cgs-progress-fill {
|
|
height: 100%;
|
|
width: 0%;
|
|
background: linear-gradient(90deg, #2f7cf6, #5db6ff);
|
|
transition: width 180ms ease;
|
|
}
|
|
`);
|
|
|
|
function createPanel(title, mode = "") {
|
|
document.querySelector("#cgs-launcher")?.remove();
|
|
const old = document.querySelector("#cgs-panel");
|
|
if (old) old.remove();
|
|
|
|
const panel = document.createElement("section");
|
|
panel.id = "cgs-panel";
|
|
if (mode) panel.classList.add(mode);
|
|
panel.innerHTML = `
|
|
<div class="cgs-head">
|
|
<span>${escapeHtml(title)} <span class="cgs-muted">v${SCRIPT_VERSION}</span></span>
|
|
<button class="cgs-btn" type="button" data-cgs-toggle>_</button>
|
|
</div>
|
|
<div class="cgs-body"></div>
|
|
`;
|
|
document.body.appendChild(panel);
|
|
panel.querySelector("[data-cgs-toggle]").addEventListener("click", () => {
|
|
panel.classList.toggle("cgs-minimized");
|
|
});
|
|
return panel.querySelector(".cgs-body");
|
|
}
|
|
|
|
function createErrorPanel(error) {
|
|
if (!document.body) return;
|
|
const body = createPanel("Suno Helper Error", "cgs-error");
|
|
body.innerHTML = `
|
|
<div class="cgs-muted">스크립트 UI를 만드는 중 오류가 났어.</div>
|
|
<div class="cgs-status">${escapeHtml(error?.message || String(error))}</div>
|
|
`;
|
|
}
|
|
|
|
function setStatus(body, text) {
|
|
const status = body.querySelector(".cgs-status");
|
|
if (status) status.textContent = text;
|
|
}
|
|
|
|
function updateProgress(body, done, total, label = "진행") {
|
|
const progressLabel = body?.querySelector?.("[data-cgs-progress-label]");
|
|
const progressFill = body?.querySelector?.("[data-cgs-progress-fill]");
|
|
if (!progressLabel || !progressFill) return;
|
|
const safeTotal = Math.max(0, Number(total || 0));
|
|
const safeDone = Math.max(0, Math.min(Number(done || 0), safeTotal || Number(done || 0)));
|
|
const percent = safeTotal ? Math.round((safeDone / safeTotal) * 1000) / 10 : 0;
|
|
progressLabel.textContent = `${label} ${safeDone}/${safeTotal}${safeTotal ? ` (${percent}%)` : ""}`;
|
|
progressFill.style.width = `${safeTotal ? percent : 0}%`;
|
|
}
|
|
|
|
function formatSyncSummary(data, fallbackRequested = 0) {
|
|
const requested = Number(data?.requested ?? data?.received ?? fallbackRequested ?? 0);
|
|
const existing = Number(data?.existingRequested ?? data?.existing ?? 0);
|
|
const missing = Number(data?.missingRequested ?? data?.missing ?? 0);
|
|
const invalid = Number(data?.invalidRequested ?? 0);
|
|
return `로컬 확인: 요청 ${requested}곡 / 이미 있음 ${existing}곡 / 새로 필요 ${missing}곡${invalid ? ` / ID 없음 ${invalid}곡` : ""}`;
|
|
}
|
|
|
|
function showSyncSummary(body, data, fallbackRequested = 0) {
|
|
const syncSummary = body?.querySelector?.("[data-cgs-sync-summary]");
|
|
const text = formatSyncSummary(data, fallbackRequested);
|
|
const requested = Number(data?.requested ?? data?.received ?? fallbackRequested ?? 0);
|
|
const existing = Number(data?.existingRequested ?? data?.existing ?? 0);
|
|
updateProgress(body, existing, requested, "로컬 확인");
|
|
if (syncSummary) syncSummary.textContent = text;
|
|
return text;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function normalizeBlock(value) {
|
|
return String(value ?? "")
|
|
.replace(/^\s*```(?:txt|text|markdown|md)?\s*/i, "")
|
|
.replace(/\s*```\s*$/i, "")
|
|
.replace(/\r\n/g, "\n")
|
|
.trim();
|
|
}
|
|
|
|
function getConversationText() {
|
|
const turns = [...document.querySelectorAll("main article, [data-message-author-role='assistant'], .markdown")];
|
|
const versionTurns = turns
|
|
.map((node) => node.innerText || node.textContent || "")
|
|
.filter((text) => /Version\s+[A-Z]\s*-/i.test(text));
|
|
return (versionTurns.at(-1) || document.body.innerText || "").replace(/\r\n/g, "\n");
|
|
}
|
|
|
|
function parseVersions(text) {
|
|
const normalized = text.replace(/\r\n/g, "\n");
|
|
const headerPattern = /(?:^|\n)(?:#{1,6}\s*)?Version\s+([A-Z])\s*-\s*([^\n]+)/gi;
|
|
const headers = [];
|
|
let match;
|
|
while ((match = headerPattern.exec(normalized))) {
|
|
headers.push({
|
|
start: match.index + (match[0].startsWith("\n") ? 1 : 0),
|
|
afterHeader: headerPattern.lastIndex,
|
|
letter: match[1].toUpperCase(),
|
|
name: match[2].trim(),
|
|
});
|
|
}
|
|
|
|
return headers.map((header, index) => {
|
|
const next = headers[index + 1]?.start ?? normalized.length;
|
|
const block = normalized.slice(header.afterHeader, next);
|
|
const payload = {
|
|
version: `Version ${header.letter} - ${header.name}`,
|
|
capturedAt: new Date().toISOString(),
|
|
};
|
|
|
|
for (const def of FIELD_DEFS) {
|
|
payload[def.key] = extractField(block, def.aliases);
|
|
}
|
|
return payload;
|
|
}).filter((payload) => payload.title || payload.style || payload.lyrics);
|
|
}
|
|
|
|
function extractField(block, aliases) {
|
|
const labels = [];
|
|
for (const def of FIELD_DEFS) {
|
|
for (const alias of def.aliases) {
|
|
labels.push(escapeRegExp(alias));
|
|
}
|
|
}
|
|
const target = aliases.map(escapeRegExp).join("|");
|
|
const labelPattern = new RegExp(`(?:^|\\n)\\s*\\[(?:${target})\\]\\s*\\n`, "i");
|
|
const match = labelPattern.exec(block);
|
|
if (!match) return "";
|
|
|
|
const start = match.index + match[0].length;
|
|
const rest = block.slice(start);
|
|
const nextPattern = new RegExp(`\\n\\s*\\[(?:${labels.join("|")})\\]\\s*\\n`, "i");
|
|
const next = nextPattern.exec(rest);
|
|
const raw = next ? rest.slice(0, next.index) : rest;
|
|
return normalizeBlock(raw.replace(/\n-{3,}\s*$/g, ""));
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function formatPayload(payload) {
|
|
return FIELD_DEFS
|
|
.filter((def) => payload[def.key])
|
|
.map((def) => `[${def.label}]\n\n${payload[def.key]}`)
|
|
.join("\n\n");
|
|
}
|
|
|
|
function emptySlots() {
|
|
return Array.from({ length: SLOT_COUNT }, () => null);
|
|
}
|
|
|
|
async function readSlots() {
|
|
const raw = await GM_getValue(STORAGE_KEY, "");
|
|
if (!raw) return emptySlots();
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) return emptySlots().map((_, index) => parsed[index] || null);
|
|
if (parsed && typeof parsed === "object") return [parsed, ...emptySlots()].slice(0, SLOT_COUNT);
|
|
} catch {
|
|
return emptySlots();
|
|
}
|
|
return emptySlots();
|
|
}
|
|
|
|
async function saveSlots(slots) {
|
|
await GM_setValue(STORAGE_KEY, JSON.stringify(emptySlots().map((_, index) => slots[index] || null)));
|
|
}
|
|
|
|
async function savePayload(payload, slotIndex) {
|
|
const slots = await readSlots();
|
|
slots[slotIndex] = { ...payload, savedAt: new Date().toISOString() };
|
|
await saveSlots(slots);
|
|
GM_setClipboard(formatPayload(payload), "text");
|
|
}
|
|
|
|
async function readPayload(slotIndex = 0) {
|
|
const slots = await readSlots();
|
|
return slots[slotIndex] || null;
|
|
}
|
|
|
|
function slotLabel(payload, index) {
|
|
if (!payload) return `Slot ${index + 1} - 비어있음`;
|
|
return `Slot ${index + 1} - ${payload.title || payload.version || "저장됨"}`;
|
|
}
|
|
|
|
function bootChatGPT() {
|
|
clearInterval(chatRenderTimer);
|
|
const body = createPanel("ChatGPT -> Suno", "cgs-chatgpt");
|
|
body.innerHTML = `
|
|
<div class="cgs-muted">현재 답변에서 Version A/B/C를 찾아서 저장해.</div>
|
|
<div data-cgs-list></div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-rescan>다시 찾기</button>
|
|
</div>
|
|
<div class="cgs-status"></div>
|
|
`;
|
|
|
|
const render = async () => {
|
|
const list = body.querySelector("[data-cgs-list]");
|
|
const versions = parseVersions(getConversationText());
|
|
list.innerHTML = "";
|
|
|
|
if (!versions.length) {
|
|
document.querySelector("#cgs-panel")?.remove();
|
|
return;
|
|
}
|
|
|
|
const slots = await readSlots();
|
|
for (const payload of versions) {
|
|
const row = document.createElement("div");
|
|
row.className = "cgs-row";
|
|
row.innerHTML = `
|
|
<div class="cgs-version" title="${escapeHtml(payload.version)}">${escapeHtml(payload.version)}</div>
|
|
<button class="cgs-btn cgs-primary" type="button" data-slot="0">S1</button>
|
|
<button class="cgs-btn cgs-primary" type="button" data-slot="1">S2</button>
|
|
<button class="cgs-btn" type="button">복사</button>
|
|
`;
|
|
const copyButton = row.querySelector("button:not([data-slot])");
|
|
for (const saveButton of row.querySelectorAll("[data-slot]")) {
|
|
saveButton.title = slotLabel(slots[Number(saveButton.dataset.slot)], Number(saveButton.dataset.slot));
|
|
saveButton.addEventListener("click", async () => {
|
|
const slotIndex = Number(saveButton.dataset.slot);
|
|
await savePayload(payload, slotIndex);
|
|
setStatus(body, `${payload.version} -> Slot ${slotIndex + 1} 저장하고 복사했어.`);
|
|
});
|
|
}
|
|
copyButton.addEventListener("click", () => {
|
|
GM_setClipboard(formatPayload(payload), "text");
|
|
setStatus(body, `${payload.version} 전체 텍스트를 복사했어.`);
|
|
});
|
|
list.appendChild(row);
|
|
}
|
|
setStatus(body, `${versions.length}개 버전을 찾았어.`);
|
|
};
|
|
|
|
body.querySelector("[data-cgs-rescan]").addEventListener("click", () => render());
|
|
render();
|
|
chatRenderTimer = setInterval(() => {
|
|
if (document.body.contains(body)) render();
|
|
}, 3000);
|
|
}
|
|
|
|
function getAllControls() {
|
|
const selectors = [
|
|
"textarea",
|
|
"input:not([type]), input[type='text'], input[type='search']",
|
|
"[contenteditable='true']",
|
|
"[role='textbox']",
|
|
".ProseMirror",
|
|
];
|
|
const controls = [...document.querySelectorAll(selectors.join(","))];
|
|
return [...new Set(controls)].filter(isVisibleControl);
|
|
}
|
|
|
|
function isVisibleControl(node) {
|
|
const rect = node.getBoundingClientRect();
|
|
const style = getComputedStyle(node);
|
|
return rect.width > 20 && rect.height > 16 && style.display !== "none" && style.visibility !== "hidden";
|
|
}
|
|
|
|
function controlText(node) {
|
|
const attrs = [
|
|
node.getAttribute("aria-label"),
|
|
node.getAttribute("placeholder"),
|
|
node.getAttribute("name"),
|
|
node.getAttribute("id"),
|
|
node.getAttribute("data-testid"),
|
|
node.className,
|
|
].filter(Boolean).join(" ");
|
|
|
|
const area = closestUsefulContainer(node);
|
|
const nearby = area ? (area.innerText || area.textContent || "") : "";
|
|
return `${attrs} ${nearby}`.toLowerCase();
|
|
}
|
|
|
|
function closestUsefulContainer(node) {
|
|
let current = node;
|
|
for (let i = 0; i < 5 && current; i += 1) {
|
|
const text = current.innerText || current.textContent || "";
|
|
if (text.length > 0 && text.length < 1200) return current;
|
|
current = current.parentElement;
|
|
}
|
|
return node.parentElement;
|
|
}
|
|
|
|
function scoreControl(node, keywords) {
|
|
const haystack = controlText(node);
|
|
let score = 0;
|
|
for (const keyword of keywords) {
|
|
const lower = keyword.toLowerCase();
|
|
if (haystack.includes(lower)) score += lower.length > 6 ? 4 : 2;
|
|
}
|
|
const tag = node.tagName.toLowerCase();
|
|
if (tag === "textarea") score += 1;
|
|
if ((node.getAttribute("placeholder") || "").toLowerCase().includes(keywords[0].toLowerCase())) score += 6;
|
|
return score;
|
|
}
|
|
|
|
function findBestControl(def, used) {
|
|
const keywordsByField = {
|
|
title: ["title", "song title", "제목"],
|
|
style: ["style", "style of music", "describe style", "음악", "장르"],
|
|
lyrics: ["lyrics", "enter lyrics", "가사"],
|
|
exclude: ["exclude", "negative", "avoid", "제외"],
|
|
extend: ["extend", "continue", "추가"],
|
|
};
|
|
const keywords = keywordsByField[def.key] || def.aliases;
|
|
return getAllControls()
|
|
.filter((node) => !used.has(node))
|
|
.map((node) => ({ node, score: scoreControl(node, keywords) }))
|
|
.filter((item) => item.score > 0)
|
|
.sort((a, b) => b.score - a.score)[0]?.node || null;
|
|
}
|
|
|
|
function setControlValue(node, value) {
|
|
node.focus();
|
|
const tag = node.tagName.toLowerCase();
|
|
if (tag === "textarea" || tag === "input") {
|
|
const prototype = tag === "textarea" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
if (setter) setter.call(node, value);
|
|
else node.value = value;
|
|
} else {
|
|
node.textContent = value;
|
|
node.innerText = value;
|
|
}
|
|
|
|
node.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
node.dispatchEvent(new Event("change", { bubbles: true }));
|
|
node.blur();
|
|
}
|
|
|
|
function fillSuno(payload) {
|
|
const used = new Set();
|
|
const results = [];
|
|
for (const def of FIELD_DEFS) {
|
|
const value = payload[def.key];
|
|
if (!value || def.key === "extend") continue;
|
|
const control = findBestControl(def, used);
|
|
if (!control) {
|
|
results.push(`${def.label}: 못 찾음`);
|
|
continue;
|
|
}
|
|
setControlValue(control, value);
|
|
used.add(control);
|
|
results.push(`${def.label}: 입력`);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function safeFilename(value, fallback = "suno-track") {
|
|
return String(value || fallback)
|
|
.replace(/[\\/:*?"<>|]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 120) || fallback;
|
|
}
|
|
|
|
function extensionFromUrl(url, fallback) {
|
|
const clean = String(url || "").split("?")[0].split("#")[0];
|
|
const match = /\.([a-z0-9]{2,5})$/i.exec(clean);
|
|
return (match?.[1] || fallback).toLowerCase();
|
|
}
|
|
|
|
function normalizeSunoMediaUrl(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) return "";
|
|
if (/^https?:\/\//i.test(raw)) return raw.replace(/\\u002F/g, "/");
|
|
if (raw.startsWith("//")) return `https:${raw}`;
|
|
if (raw.startsWith("/")) return `https://suno.com${raw}`;
|
|
return raw;
|
|
}
|
|
|
|
function unique(values) {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
function extractUrls(text, pattern) {
|
|
return unique([...String(text || "").matchAll(pattern)].map((match) => match[0].replace(/\\u002F/g, "/")));
|
|
}
|
|
|
|
function collectStringValues(value, output = []) {
|
|
if (value == null) return output;
|
|
if (typeof value === "string") {
|
|
output.push(value);
|
|
return output;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectStringValues(item, output);
|
|
return output;
|
|
}
|
|
if (typeof value === "object") {
|
|
for (const item of Object.values(value)) collectStringValues(item, output);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function extractAudioUrlsFromValue(value) {
|
|
const text = collectStringValues(value).join("\n");
|
|
return unique([
|
|
...extractUrls(text, /https?:\\?\/\\?\/cdn[0-9]*\.suno\.ai\\?\/[^"' <>)]+?\.(?:mp3|m4a|oga|ogg)(?:\?[^"' <>)]+)?/gi),
|
|
...extractUrls(text, /https?:\/\/[^"' <>)]+?\.(?:mp3|m4a|oga|ogg)(?:\?[^"' <>)]+)?/gi),
|
|
].map((url) => normalizeSunoMediaUrl(url.replace(/\\\//g, "/"))));
|
|
}
|
|
|
|
function readJsonScripts() {
|
|
const results = [];
|
|
for (const script of document.querySelectorAll("script")) {
|
|
const text = script.textContent || "";
|
|
if (!text.includes("suno") && !text.includes("audio") && !text.includes("image")) continue;
|
|
try {
|
|
results.push(JSON.parse(text));
|
|
} catch {
|
|
results.push(text);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function readSunoDataSources() {
|
|
return [
|
|
...readJsonScripts(),
|
|
...capturedSunoResponses.map((item) => item.data),
|
|
...((typeof unsafeWindow !== "undefined" && unsafeWindow.__NEXT_DATA__) ? [unsafeWindow.__NEXT_DATA__] : []),
|
|
];
|
|
}
|
|
|
|
function flattenObjects(value, output = []) {
|
|
if (!value || typeof value !== "object") return output;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) flattenObjects(item, output);
|
|
return output;
|
|
}
|
|
output.push(value);
|
|
for (const item of Object.values(value)) flattenObjects(item, output);
|
|
return output;
|
|
}
|
|
|
|
function firstValue(object, keys) {
|
|
for (const key of keys) {
|
|
if (object?.[key]) return object[key];
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function isLikelyTrackObject(object) {
|
|
if (!object || typeof object !== "object" || Array.isArray(object)) return false;
|
|
const id = firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]);
|
|
const hasTrackText = firstValue(object, ["title", "name", "prompt", "gpt_description_prompt", "metadata"]);
|
|
const hasMedia = firstValue(object, ["audio_url", "audioUrl", "stream_audio_url", "streamAudioUrl", "play_path", "playPath", "image_url", "imageUrl", "image_large_url", "wav_url", "wavUrl"]);
|
|
return Boolean(id && (hasTrackText || hasMedia));
|
|
}
|
|
|
|
function normalizeTrackObject(object) {
|
|
const id = String(firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]) || "").trim();
|
|
const title = String(firstValue(object, ["title", "name"]) || id || "suno-track").trim();
|
|
const imageUrl = firstValue(object, ["image_url", "imageUrl", "image_large_url", "imageLargeUrl", "cover_url", "coverUrl"]);
|
|
const audioPath = firstValue(object, ["audio_url", "audioUrl", "stream_audio_url", "streamAudioUrl", "play_url", "playUrl", "play_path", "playPath", "mp3_url", "mp3Url", "video_url", "videoUrl"]);
|
|
const audioUrls = extractAudioUrlsFromValue(object);
|
|
const audioUrl = normalizeSunoMediaUrl(audioPath) || audioUrls[0] || "";
|
|
const wavPath = firstValue(object, ["wav_url", "wavUrl", "download_url", "downloadUrl"]);
|
|
const wavUrl = normalizeSunoMediaUrl(wavPath);
|
|
const lyrics = firstValue(object, ["prompt", "lyrics", "lyric", "text"]);
|
|
const style = firstValue(object, ["gpt_description_prompt", "style", "tags", "description"]);
|
|
const model = firstValue(object, ["model_name", "model", "major_model_version"]);
|
|
const createdAt = firstValue(object, ["created_at", "createdAt"]);
|
|
const owner = firstValue(object, ["username", "user_name", "userName", "handle", "user_handle", "userHandle", "display_name", "displayName"]);
|
|
const ownerFromUser = object.user && typeof object.user === "object"
|
|
? firstValue(object.user, ["username", "user_name", "userName", "handle", "display_name", "displayName"])
|
|
: "";
|
|
return {
|
|
id,
|
|
title,
|
|
lyrics,
|
|
style,
|
|
model,
|
|
createdAt,
|
|
owner: owner || ownerFromUser,
|
|
audioUrl,
|
|
detectedAudioUrls: audioUrls,
|
|
wavUrl,
|
|
imageUrl,
|
|
pageUrl: location.href,
|
|
metadata: object,
|
|
capturedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function mergeTrack(target, source) {
|
|
if (!target) return source;
|
|
for (const key of ["title", "lyrics", "style", "model", "createdAt", "audioUrl", "wavUrl", "imageUrl", "pageUrl"]) {
|
|
if (!target[key] && source[key]) target[key] = source[key];
|
|
}
|
|
target.detectedAudioUrls = unique([...(target.detectedAudioUrls || []), ...(source.detectedAudioUrls || [])]);
|
|
target.metadata = target.metadata && Object.keys(target.metadata).length ? target.metadata : source.metadata;
|
|
return target;
|
|
}
|
|
|
|
function normalizeMatchText(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function isBadTrackTitle(value) {
|
|
const text = String(value || "").trim();
|
|
if (!text) return true;
|
|
if (/^v\d+(?:\.\d+)*$/i.test(text)) return true;
|
|
if (/^\d+:\d{2}$/.test(text)) return true;
|
|
if (/^(liked|public|uploads|newest|songs|playlists)$/i.test(text)) return true;
|
|
return false;
|
|
}
|
|
|
|
function extractIdsFromText(value) {
|
|
return unique([...String(value || "").matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi)].map((match) => match[0]));
|
|
}
|
|
|
|
function readNodeMediaText(node) {
|
|
const values = [];
|
|
let current = node;
|
|
for (let depth = 0; depth < 5 && current; depth += 1) {
|
|
if (current.currentSrc || current.src) values.push(current.currentSrc || current.src);
|
|
const style = current.getAttribute?.("style") || "";
|
|
if (style) values.push(style);
|
|
current = current.parentElement;
|
|
}
|
|
return values.join("\n");
|
|
}
|
|
|
|
function inferVisibleRowTitle(text) {
|
|
const lines = String(text || "")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
for (const line of lines) {
|
|
if (isBadTrackTitle(line)) continue;
|
|
if (/^(play|like|liked|public|uploads|newest|filters?)$/i.test(line)) continue;
|
|
if (/^\d+$/.test(line)) continue;
|
|
if (line.length > 90) continue;
|
|
return line;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function getCurrentSunoUsername() {
|
|
const text = document.body.innerText || "";
|
|
const match = /([A-Za-z0-9_.-]{3,40})\s+\d+\s+credits/i.exec(text);
|
|
return match?.[1] || "";
|
|
}
|
|
|
|
function getVisibleLibraryRows() {
|
|
const rows = [];
|
|
const seen = new Set();
|
|
const mediaNodes = [...document.querySelectorAll("img, [style*='background-image']")];
|
|
for (const node of mediaNodes) {
|
|
const rect = node.getBoundingClientRect();
|
|
if (rect.width < 30 || rect.height < 30 || rect.bottom < 0 || rect.top > window.innerHeight) continue;
|
|
let current = node;
|
|
for (let depth = 0; depth < 8 && current; depth += 1) {
|
|
const text = (current.innerText || current.textContent || "").trim();
|
|
const currentRect = current.getBoundingClientRect();
|
|
if (
|
|
text.length > 20 &&
|
|
text.length < 1800 &&
|
|
currentRect.width > 250 &&
|
|
currentRect.height > 45 &&
|
|
/v\d|BPM|Korean|rock|pop|metal|랩|가사|female|male|vocal|Suno/i.test(text)
|
|
) {
|
|
const key = normalizeMatchText(text);
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
const mediaText = `${readNodeMediaText(node)}\n${current.innerHTML || ""}`;
|
|
rows.push({
|
|
text,
|
|
normalized: key,
|
|
title: inferVisibleRowTitle(text),
|
|
ids: extractIdsFromText(mediaText),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function trackMatchesVisibleRows(track, rows) {
|
|
return getTrackVisibleIndex(track, rows) >= 0;
|
|
}
|
|
|
|
function getTrackVisibleIndex(track, rows) {
|
|
if (!rows.length) return -1;
|
|
const title = normalizeMatchText(track.title);
|
|
const style = normalizeMatchText(track.style || track.lyrics || "");
|
|
const id = String(track.id || "");
|
|
const idIndex = rows.findIndex((row) => id && row.ids?.includes(id));
|
|
if (idIndex >= 0) return idIndex;
|
|
if (!title || title.length > 160 || isBadTrackTitle(track.title)) return -1;
|
|
return rows.findIndex((row) => {
|
|
if (!row.normalized.includes(title)) return false;
|
|
if (!style) return true;
|
|
const styleNeedle = style.slice(0, Math.min(42, style.length));
|
|
return styleNeedle.length < 16 || row.normalized.includes(styleNeedle);
|
|
});
|
|
}
|
|
|
|
function trackMatchesCurrentUser(track, username) {
|
|
if (!username || !track.owner) return true;
|
|
return normalizeMatchText(track.owner) === normalizeMatchText(username);
|
|
}
|
|
|
|
function scanSunoLibraryTracks() {
|
|
const sources = readSunoDataSources();
|
|
const objects = sources.flatMap((item) => flattenObjects(item));
|
|
const tracks = new Map();
|
|
|
|
for (const object of objects) {
|
|
if (!isLikelyTrackObject(object)) continue;
|
|
const track = normalizeTrackObject(object);
|
|
if (!track.id) continue;
|
|
tracks.set(track.id, mergeTrack(tracks.get(track.id), track));
|
|
}
|
|
|
|
const htmlText = document.documentElement.innerHTML || "";
|
|
const idMatches = [...htmlText.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi)];
|
|
const fallbackIds = unique(idMatches
|
|
.filter((match) => /clip|audio|image|title|cdn[0-9]*\.suno\.ai/i.test(htmlText.slice(Math.max(0, match.index - 500), match.index + 500)))
|
|
.map((match) => match[0]));
|
|
for (const id of fallbackIds) {
|
|
if (tracks.has(id)) continue;
|
|
tracks.set(id, {
|
|
id,
|
|
title: id,
|
|
audioUrl: "",
|
|
wavUrl: "",
|
|
imageUrl: "",
|
|
pageUrl: location.href,
|
|
metadata: {},
|
|
capturedAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const visibleRows = getVisibleLibraryRows();
|
|
for (const track of tracks.values()) {
|
|
const row = visibleRows.find((item) => track.id && item.ids?.includes(track.id));
|
|
if (row?.title && isBadTrackTitle(track.title)) track.title = row.title;
|
|
}
|
|
const currentUser = getCurrentSunoUsername();
|
|
return [...tracks.values()]
|
|
.filter((track) => track.id || track.audioUrl || track.imageUrl)
|
|
.filter((track) => trackMatchesCurrentUser(track, currentUser))
|
|
.filter((track) => trackMatchesVisibleRows(track, visibleRows))
|
|
.sort((a, b) => getTrackVisibleIndex(a, visibleRows) - getTrackVisibleIndex(b, visibleRows));
|
|
}
|
|
|
|
function visibleSunoRowsHint() {
|
|
return getVisibleLibraryRows()
|
|
.map((row) => row.text.split("\n").map((line) => line.trim()).filter(Boolean)[0])
|
|
.filter((text, index, list) => text && list.indexOf(text) === index)
|
|
.slice(0, 5);
|
|
}
|
|
|
|
function findCurrentSunoTrack() {
|
|
const pageText = document.body.innerText || "";
|
|
const htmlText = document.documentElement.innerHTML || "";
|
|
const sources = readSunoDataSources();
|
|
const objects = sources.flatMap((item) => flattenObjects(item));
|
|
const audioElements = [...document.querySelectorAll("audio, video")].map((node) => node.currentSrc || node.src);
|
|
const allText = [htmlText, ...sources.map((item) => typeof item === "string" ? item : JSON.stringify(item))].join("\n");
|
|
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
|
const ids = unique([...allText.matchAll(uuidPattern)].map((match) => match[0]));
|
|
const cdnAudioUrls = extractUrls(allText, /https:\/\/cdn[0-9]*\.suno\.ai\/[^"' <>)]+?\.(?:mp3|wav|m4a|oga|ogg)(?:\?[^"' <>)]+)?/gi);
|
|
const cdnImageUrls = extractUrls(allText, /https:\/\/cdn[0-9]*\.suno\.ai\/[^"' <>)]+?\.(?:png|jpg|jpeg|webp)(?:\?[^"' <>)]+)?/gi);
|
|
const audioUrls = unique([...audioElements, ...cdnAudioUrls]);
|
|
const imageUrls = unique([
|
|
...cdnImageUrls,
|
|
...[...document.images].map((img) => img.currentSrc || img.src).filter((url) => /suno|cdn/i.test(url)),
|
|
]);
|
|
|
|
const metadataObjects = objects.filter((item) => {
|
|
const text = JSON.stringify(item);
|
|
return /audio_url|image_url|clip|prompt|metadata|title|gpt_description_prompt/i.test(text)
|
|
&& (ids.some((id) => text.includes(id)) || /cdn[0-9]*\.suno\.ai/i.test(text));
|
|
});
|
|
const metadata = metadataObjects[0] || {};
|
|
const title =
|
|
metadata.title ||
|
|
metadata.name ||
|
|
document.querySelector("h1")?.innerText ||
|
|
document.querySelector("[data-testid*='title' i]")?.innerText ||
|
|
"";
|
|
const id = metadata.id || metadata.clip_id || metadata.clipId || ids[0] || "";
|
|
const imageUrl = metadata.image_url || metadata.imageUrl || metadata.image_large_url || imageUrls[0] || "";
|
|
const audioUrl = metadata.audio_url || metadata.audioUrl || audioUrls.find((url) => /\.wav(?:\?|$)/i.test(url)) || audioUrls[0] || "";
|
|
const wavUrl = metadata.wav_url || metadata.wavUrl || audioUrls.find((url) => /\.wav(?:\?|$)/i.test(url)) || "";
|
|
|
|
return {
|
|
id,
|
|
title: String(title || "suno-track").trim(),
|
|
audioUrl,
|
|
wavUrl,
|
|
imageUrl,
|
|
pageUrl: location.href,
|
|
detectedIds: ids,
|
|
detectedAudioUrls: audioUrls,
|
|
detectedImageUrls: imageUrls,
|
|
metadata,
|
|
pageTextPreview: pageText.slice(0, 3000),
|
|
capturedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function downloadUrl(url, name, onerror, onload) {
|
|
if (!url) {
|
|
onerror?.("URL 없음");
|
|
return;
|
|
}
|
|
GM_download({
|
|
url,
|
|
name,
|
|
saveAs: false,
|
|
onload: () => onload?.(),
|
|
onerror: (error) => onerror?.(error?.error || String(error)),
|
|
});
|
|
}
|
|
|
|
async function callSunoJson(url, options = {}) {
|
|
const response = await callSunoText(url, options);
|
|
const finalWavUrl = extractWavUrl(response.finalUrl || url);
|
|
if (response.ok && finalWavUrl) return finalWavUrl;
|
|
const text = response.text || "";
|
|
const data = tryParseJson(text) || text;
|
|
rememberSunoResponse(url, data);
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}: ${String(text).slice(0, 160)}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function callSunoText(url, options = {}) {
|
|
const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
|
|
const headers = {
|
|
"accept": "application/json, text/plain, */*",
|
|
...(options.method && options.method !== "GET" && options.method !== "HEAD" ? { "content-type": "application/json" } : {}),
|
|
...(options.headers || {}),
|
|
};
|
|
if (typeof GM_xmlhttpRequest === "function" && /^https?:\/\/[^/]*suno\./i.test(url)) {
|
|
return callTampermonkeyRequest(url, { ...options, headers });
|
|
}
|
|
try {
|
|
const response = await page.fetch(url, {
|
|
credentials: "include",
|
|
...options,
|
|
headers,
|
|
});
|
|
const finalUrl = response.url || url;
|
|
if (response.ok && extractWavUrl(finalUrl)) {
|
|
return {
|
|
ok: true,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
finalUrl,
|
|
text: "",
|
|
};
|
|
}
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
finalUrl,
|
|
text: await response.text(),
|
|
};
|
|
} catch (fetchError) {
|
|
if (typeof GM_xmlhttpRequest !== "function") throw fetchError;
|
|
return callTampermonkeyRequest(url, { ...options, headers });
|
|
}
|
|
}
|
|
|
|
function callTampermonkeyRequest(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
GM_xmlhttpRequest({
|
|
method: options.method || "GET",
|
|
url,
|
|
headers: options.headers || {},
|
|
data: options.body,
|
|
anonymous: false,
|
|
withCredentials: true,
|
|
timeout: 45000,
|
|
onload: (response) => {
|
|
resolve({
|
|
ok: response.status >= 200 && response.status < 300,
|
|
status: response.status,
|
|
statusText: response.statusText || "",
|
|
finalUrl: response.finalUrl || url,
|
|
text: response.responseText || "",
|
|
});
|
|
},
|
|
onerror: (error) => reject(new Error(error?.error || "GM_xmlhttpRequest failed")),
|
|
ontimeout: () => reject(new Error("GM_xmlhttpRequest timeout")),
|
|
});
|
|
});
|
|
}
|
|
|
|
function callTampermonkeyBytes(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
GM_xmlhttpRequest({
|
|
method: options.method || "GET",
|
|
url,
|
|
headers: options.headers || {},
|
|
anonymous: false,
|
|
withCredentials: true,
|
|
timeout: 120000,
|
|
responseType: "arraybuffer",
|
|
onload: (response) => {
|
|
resolve({
|
|
ok: response.status >= 200 && response.status < 300,
|
|
status: response.status,
|
|
statusText: response.statusText || "",
|
|
finalUrl: response.finalUrl || url,
|
|
buffer: response.response,
|
|
});
|
|
},
|
|
onerror: (error) => reject(new Error(error?.error || "GM_xmlhttpRequest bytes failed")),
|
|
ontimeout: () => reject(new Error("GM_xmlhttpRequest bytes timeout")),
|
|
});
|
|
});
|
|
}
|
|
|
|
function arrayBufferToBase64(buffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
const chunkSize = 0x8000;
|
|
let binary = "";
|
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
|
const chunk = bytes.subarray(index, index + chunkSize);
|
|
binary += String.fromCharCode(...chunk);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function extractWavUrl(value) {
|
|
if (!value) return "";
|
|
if (typeof value === "string") {
|
|
const direct = value.match(/https?:\/\/[^"' <>)]+?\.wav(?:\?[^"' <>)]+)?/i)?.[0] || "";
|
|
if (direct) return normalizeSunoMediaUrl(direct);
|
|
return /\.wav(?:\?|$)/i.test(value) ? normalizeSunoMediaUrl(value) : "";
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const found = extractWavUrl(item);
|
|
if (found) return found;
|
|
}
|
|
return "";
|
|
}
|
|
if (typeof value === "object") {
|
|
const directKeys = [
|
|
"url",
|
|
"download_url",
|
|
"downloadUrl",
|
|
"wav_url",
|
|
"wavUrl",
|
|
"wav_file_url",
|
|
"wavFileUrl",
|
|
"file_url",
|
|
"fileUrl",
|
|
"signed_url",
|
|
"signedUrl",
|
|
"audio_url",
|
|
"audioUrl",
|
|
"file",
|
|
];
|
|
for (const key of directKeys) {
|
|
const found = extractWavUrl(value[key]);
|
|
if (found) return found;
|
|
}
|
|
for (const item of Object.values(value)) {
|
|
const found = extractWavUrl(item);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function canReadUrl(url) {
|
|
if (!url) return false;
|
|
const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
|
|
const methods = [
|
|
{ method: "HEAD" },
|
|
{ method: "GET", headers: { range: "bytes=0-0" } },
|
|
];
|
|
for (const options of methods) {
|
|
try {
|
|
const response = await page.fetch(url, {
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
...options,
|
|
});
|
|
if (response.ok) return true;
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] URL probe failed", url, error);
|
|
}
|
|
if (typeof GM_xmlhttpRequest === "function") {
|
|
try {
|
|
const response = await callTampermonkeyRequest(url, {
|
|
...options,
|
|
headers: options.headers || {},
|
|
});
|
|
if (response.ok || response.status === 206) return true;
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] GM URL probe failed", url, error);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function requestSunoWavUrl(track) {
|
|
if (!track.id) return extractWavUrl(track.wavUrl);
|
|
const base = `https://studio-api-prod.suno.com/api/gen/${encodeURIComponent(track.id)}`;
|
|
const cdnWavUrl = `https://cdn1.suno.ai/${encodeURIComponent(track.id)}.wav`;
|
|
try {
|
|
await callSunoJson(`${base}/convert_wav/`, { method: "POST" });
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] convert_wav failed", error);
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
try {
|
|
const data = await callSunoJson(`${base}/wav_file/`, { method: "GET" });
|
|
const direct = extractWavUrl(data);
|
|
if (direct) return direct;
|
|
if (await canReadUrl(cdnWavUrl)) return cdnWavUrl;
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] wav_file failed", error);
|
|
if (await canReadUrl(cdnWavUrl)) return cdnWavUrl;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
}
|
|
|
|
return extractWavUrl(track.wavUrl) || ((await canReadUrl(cdnWavUrl)) ? cdnWavUrl : "");
|
|
}
|
|
|
|
async function prepareSunoWavCandidate(track) {
|
|
if (!track.id) return extractWavUrl(track.wavUrl);
|
|
const base = `https://studio-api-prod.suno.com/api/gen/${encodeURIComponent(track.id)}`;
|
|
const billingDownloadUrl = `https://studio-api-prod.suno.com/api/billing/clips/${encodeURIComponent(track.id)}/download/`;
|
|
const cdnWavUrl = `https://cdn1.suno.ai/${encodeURIComponent(track.id)}.wav`;
|
|
try {
|
|
await callSunoJson(`${base}/convert_wav/`, { method: "POST" });
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] convert_wav failed; local retry will continue", error);
|
|
}
|
|
try {
|
|
const data = await callSunoJson(`${base}/wav_file/`, { method: "GET" });
|
|
const direct = extractWavUrl(data);
|
|
if (direct) return direct;
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] wav_file probe failed; local retry will continue", error);
|
|
}
|
|
try {
|
|
const data = await callSunoJson(billingDownloadUrl, { method: "GET" });
|
|
const direct = extractWavUrl(data);
|
|
if (direct) return direct;
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] billing download probe failed; local retry will continue", error);
|
|
}
|
|
return extractWavUrl(track.wavUrl) || cdnWavUrl;
|
|
}
|
|
|
|
async function downloadSunoWavBuffer(track, statusTarget) {
|
|
if (!track.id) throw new Error("clip id 없음");
|
|
const base = `https://studio-api-prod.suno.com/api/gen/${encodeURIComponent(track.id)}`;
|
|
const candidates = [];
|
|
|
|
setStatus(statusTarget, `브라우저에서 WAV 변환 요청 중: ${track.title || track.id}`);
|
|
try {
|
|
await callSunoJson(`${base}/convert_wav/`, { method: "POST" });
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] convert_wav failed before browser download", error);
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
try {
|
|
const data = await callSunoJson(`${base}/wav_file/`, { method: "GET" });
|
|
const direct = extractWavUrl(data);
|
|
if (direct) candidates.push(direct);
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] wav_file probe failed before browser download", error);
|
|
}
|
|
candidates.push(`https://studio-api-prod.suno.com/api/billing/clips/${encodeURIComponent(track.id)}/download/`);
|
|
candidates.push(`https://cdn1.suno.ai/${encodeURIComponent(track.id)}.wav`);
|
|
|
|
for (const url of unique(candidates)) {
|
|
try {
|
|
setStatus(statusTarget, `브라우저에서 WAV 받는 중: ${track.title || track.id}`);
|
|
const response = await callTampermonkeyBytes(url, { method: "GET" });
|
|
const size = response.buffer?.byteLength || 0;
|
|
if (response.ok && size > 1024 * 1024) {
|
|
return response.buffer;
|
|
}
|
|
console.warn("[ChatGPT to Suno] WAV bytes not ready", url, response.status, size);
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] WAV bytes download failed", url, error);
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
}
|
|
throw new Error("브라우저도 WAV 다운로드를 못 받았어");
|
|
}
|
|
|
|
function getMp3Candidates(track) {
|
|
const candidates = [];
|
|
if (track.audioUrl && !/\.wav(?:\?|$)/i.test(track.audioUrl)) candidates.push(track.audioUrl);
|
|
for (const url of track.detectedAudioUrls || []) {
|
|
if (url && !/\.wav(?:\?|$)/i.test(url)) candidates.push(url);
|
|
}
|
|
const metadata = track.metadata || {};
|
|
for (const url of extractAudioUrlsFromValue(metadata)) {
|
|
if (url && !/\.wav(?:\?|$)/i.test(url)) candidates.push(url);
|
|
}
|
|
for (const value of [
|
|
metadata.audio_url,
|
|
metadata.audioUrl,
|
|
metadata.stream_audio_url,
|
|
metadata.streamAudioUrl,
|
|
metadata.play_url,
|
|
metadata.playUrl,
|
|
metadata.play_path,
|
|
metadata.playPath,
|
|
metadata.mp3_url,
|
|
metadata.mp3Url,
|
|
]) {
|
|
const normalized = normalizeSunoMediaUrl(value);
|
|
if (normalized && !/\.wav(?:\?|$)/i.test(normalized)) candidates.push(normalized);
|
|
}
|
|
if (track.id) {
|
|
candidates.push(`https://cdn1.suno.ai/${encodeURIComponent(track.id)}.mp3`);
|
|
candidates.push(`https://cdn2.suno.ai/${encodeURIComponent(track.id)}.mp3`);
|
|
}
|
|
return unique(candidates);
|
|
}
|
|
|
|
async function downloadSunoMp3Buffer(track, statusTarget) {
|
|
if (track.id) {
|
|
const id = encodeURIComponent(track.id);
|
|
setStatus(statusTarget, `SUNO MP3 다운로드 권한 요청 중: ${track.title || track.id}`);
|
|
for (const url of [
|
|
`https://studio-api-prod.suno.com/api/gen/${id}/increment_action_count/`,
|
|
`https://studio-api-prod.suno.com/api/billing/clips/${id}/download/`,
|
|
]) {
|
|
try {
|
|
await callSunoJson(url, { method: "POST" });
|
|
} catch (postError) {
|
|
try {
|
|
await callSunoJson(url, { method: "GET" });
|
|
} catch (getError) {
|
|
console.warn("[ChatGPT to Suno] MP3 preflight failed", url, postError, getError);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const candidates = getMp3Candidates(track);
|
|
if (!candidates.length) throw new Error("MP3 후보 URL 없음");
|
|
for (const url of candidates) {
|
|
try {
|
|
setStatus(statusTarget, `브라우저에서 MP3 받는 중: ${track.title || track.id}`);
|
|
const response = await callTampermonkeyBytes(url, { method: "GET" });
|
|
const size = response.buffer?.byteLength || 0;
|
|
if (response.ok && size > 128 * 1024) {
|
|
track.audioUrl = url;
|
|
return response.buffer;
|
|
}
|
|
console.warn("[ChatGPT to Suno] MP3 bytes not ready", url, response.status, size);
|
|
} catch (error) {
|
|
console.warn("[ChatGPT to Suno] MP3 download failed", url, error);
|
|
}
|
|
}
|
|
throw new Error("브라우저도 MP3 다운로드를 못 받았어");
|
|
}
|
|
|
|
function downloadTextFile(text, name) {
|
|
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = name;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
}
|
|
|
|
async function downloadSunoPackage(track, statusTarget) {
|
|
const baseName = safeFilename(track.title || track.id);
|
|
const messages = [];
|
|
const fail = (label) => (reason) => {
|
|
messages.push(`${label} 실패: ${reason}`);
|
|
setStatus(statusTarget, messages.join(" / "));
|
|
};
|
|
|
|
downloadTextFile(JSON.stringify(track, null, 2), `${baseName}.metadata.json`);
|
|
messages.push("metadata 저장");
|
|
|
|
if (track.imageUrl) {
|
|
downloadUrl(track.imageUrl, `${baseName}.cover.${extensionFromUrl(track.imageUrl, "jpg")}`, fail("cover"));
|
|
messages.push("cover 요청");
|
|
}
|
|
const preparedWavUrl = await requestSunoWavUrl(track);
|
|
if (preparedWavUrl) {
|
|
downloadUrl(preparedWavUrl, `${baseName}.wav`, (reason) => {
|
|
fail("wav")(reason);
|
|
});
|
|
messages.push("wav 요청");
|
|
}
|
|
setStatus(statusTarget, messages.join(" / "));
|
|
}
|
|
|
|
async function downloadSunoBatch(tracks, statusTarget) {
|
|
if (!tracks.length) {
|
|
setStatus(statusTarget, "다운로드할 곡을 못 찾았어. 라이브러리에서 스크롤을 좀 내려서 곡을 로드한 뒤 다시 눌러봐.");
|
|
return;
|
|
}
|
|
|
|
const manifest = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
count: tracks.length,
|
|
tracks,
|
|
};
|
|
downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`);
|
|
setStatus(statusTarget, `${tracks.length}곡 감지. manifest 저장 후 순차 다운로드 시작.`);
|
|
|
|
for (let index = 0; index < tracks.length; index += 1) {
|
|
const track = tracks[index];
|
|
setStatus(statusTarget, `${index + 1}/${tracks.length} 요청 중: ${track.title || track.id}`);
|
|
await downloadSunoPackage(track, statusTarget);
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
}
|
|
setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`);
|
|
}
|
|
|
|
async function exportSunoManifest(tracks, statusTarget) {
|
|
if (!tracks.length) {
|
|
setStatus(statusTarget, "내보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘.");
|
|
return;
|
|
}
|
|
const preparedTracks = [];
|
|
for (let index = 0; index < tracks.length; index += 1) {
|
|
const track = { ...tracks[index] };
|
|
setStatus(statusTarget, `${index + 1}/${tracks.length} WAV 준비 중: ${track.title || track.id}`);
|
|
track.wavUrl = await requestSunoWavUrl(track);
|
|
if (!track.wavUrl) {
|
|
setStatus(statusTarget, `${track.title || track.id} WAV URL을 아직 못 받아서 건너뜀`);
|
|
continue;
|
|
}
|
|
preparedTracks.push(track);
|
|
}
|
|
const manifest = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
count: preparedTracks.length,
|
|
tracks: preparedTracks,
|
|
};
|
|
downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`);
|
|
setStatus(statusTarget, `${preparedTracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`);
|
|
}
|
|
|
|
async function postToCompanion(tracks, statusTarget) {
|
|
if (!tracks.length) {
|
|
setStatus(statusTarget, "보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘.");
|
|
return;
|
|
}
|
|
const syncManifest = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
count: tracks.length,
|
|
tracks,
|
|
};
|
|
setStatus(statusTarget, `${tracks.length}곡 로컬 폴더와 비교 중...`);
|
|
const syncResponse = await fetch("http://127.0.0.1:17873/sync-index", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(syncManifest),
|
|
});
|
|
const syncText = await syncResponse.text();
|
|
if (!syncResponse.ok) {
|
|
throw new Error(syncText || `${syncResponse.status} ${syncResponse.statusText}`);
|
|
}
|
|
const syncData = tryParseJson(syncText) || {};
|
|
const syncTextSummary = showSyncSummary(statusTarget, syncData, tracks.length);
|
|
const missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : [];
|
|
if (!missingTracks.length) {
|
|
setStatus(statusTarget, `동기화 완료. ${syncTextSummary}`);
|
|
return;
|
|
}
|
|
|
|
let enqueued = 0;
|
|
let wavPending = 0;
|
|
const syncExisting = Number(syncData.existingRequested ?? syncData.existing ?? 0);
|
|
const syncTotal = Number(syncData.requested ?? syncData.received ?? tracks.length);
|
|
for (let index = 0; index < missingTracks.length; index += 1) {
|
|
const track = { ...missingTracks[index] };
|
|
updateProgress(statusTarget, syncExisting + enqueued, syncTotal, "WAV 큐 준비");
|
|
setStatus(statusTarget, `${index + 1}/${missingTracks.length} 없는 곡 WAV 변환 요청 중: ${track.title || track.id}`);
|
|
track.wavUrl = await prepareSunoWavCandidate(track);
|
|
if (!track.wavUrl) {
|
|
wavPending += 1;
|
|
setStatus(statusTarget, `${track.title || track.id} WAV 후보 URL을 못 만들어서 보류 (${wavPending}곡)`);
|
|
continue;
|
|
}
|
|
const enqueueManifest = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
count: 1,
|
|
tracks: [track],
|
|
};
|
|
const response = await fetch("http://127.0.0.1:17873/enqueue", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(enqueueManifest),
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(text || `${response.status} ${response.statusText}`);
|
|
}
|
|
enqueued += 1;
|
|
updateProgress(statusTarget, syncExisting + enqueued, syncTotal, "WAV 큐 준비");
|
|
const data = tryParseJson(text) || {};
|
|
setStatus(statusTarget, `${enqueued}/${missingTracks.length}곡 큐 추가: ${track.title || track.id} / 대기 ${data.queued ?? "?"}개`);
|
|
}
|
|
if (!enqueued) {
|
|
setStatus(statusTarget, `큐에 넣은 곡 없음. 새 곡 ${missingTracks.length}개 중 ${wavPending}개가 WAV 후보 URL 생성 실패.`);
|
|
return;
|
|
}
|
|
setStatus(statusTarget, `큐 전송 완료: 새 곡 ${enqueued}개, 보류 ${wavPending}개. ${syncTextSummary}`);
|
|
}
|
|
|
|
async function uploadTrackToCompanion(track, statusTarget, format = "mp3") {
|
|
const syncManifest = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
count: 1,
|
|
tracks: [track],
|
|
};
|
|
setStatus(statusTarget, `로컬 보관함 확인 중: ${track.title || track.id}`);
|
|
const syncResponse = await fetch("http://127.0.0.1:17873/sync-index", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(syncManifest),
|
|
});
|
|
const syncText = await syncResponse.text();
|
|
if (!syncResponse.ok) throw new Error(syncText || `${syncResponse.status} ${syncResponse.statusText}`);
|
|
const syncData = tryParseJson(syncText) || {};
|
|
const syncTextSummary = showSyncSummary(statusTarget, syncData, 1);
|
|
const missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : [];
|
|
if (!missingTracks.length) {
|
|
setStatus(statusTarget, `이미 로컬에 있음, SUNO 다운로드 생략: ${track.title || track.id}. ${syncTextSummary}`);
|
|
return {
|
|
ok: true,
|
|
skipped: true,
|
|
localExists: true,
|
|
queued: 0,
|
|
active: "",
|
|
total: 0,
|
|
done: 0,
|
|
percent: 100,
|
|
};
|
|
}
|
|
track = { ...track, ...missingTracks[0] };
|
|
const buffer = format === "wav"
|
|
? await downloadSunoWavBuffer(track, statusTarget)
|
|
: await downloadSunoMp3Buffer(track, statusTarget);
|
|
setStatus(statusTarget, `로컬 프로그램으로 ${format.toUpperCase()} 업로드 중: ${track.title || track.id}`);
|
|
const payload = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
track,
|
|
audioExt: format,
|
|
audioBase64: arrayBufferToBase64(buffer),
|
|
};
|
|
const response = await fetch("http://127.0.0.1:17873/upload-track", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(text || `${response.status} ${response.statusText}`);
|
|
}
|
|
return tryParseJson(text) || {};
|
|
}
|
|
|
|
async function uploadTracksToCompanion(tracks, statusTarget, format = "mp3") {
|
|
if (!tracks.length) {
|
|
setStatus(statusTarget, "보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘.");
|
|
return;
|
|
}
|
|
const syncManifest = {
|
|
pageUrl: location.href,
|
|
capturedAt: new Date().toISOString(),
|
|
count: tracks.length,
|
|
tracks,
|
|
};
|
|
setStatus(statusTarget, `${tracks.length}곡 로컬 폴더와 비교 중...`);
|
|
const syncResponse = await fetch("http://127.0.0.1:17873/sync-index", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(syncManifest),
|
|
});
|
|
const syncText = await syncResponse.text();
|
|
if (!syncResponse.ok) throw new Error(syncText || `${syncResponse.status} ${syncResponse.statusText}`);
|
|
const syncData = tryParseJson(syncText) || {};
|
|
const syncTextSummary = showSyncSummary(statusTarget, syncData, tracks.length);
|
|
const missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : [];
|
|
if (!missingTracks.length) {
|
|
setStatus(statusTarget, `동기화 완료. ${syncTextSummary}`);
|
|
return;
|
|
}
|
|
|
|
let uploaded = 0;
|
|
let failed = 0;
|
|
const syncExisting = Number(syncData.existingRequested ?? syncData.existing ?? 0);
|
|
const syncTotal = Number(syncData.requested ?? syncData.received ?? tracks.length);
|
|
for (let index = 0; index < missingTracks.length; index += 1) {
|
|
const track = { ...missingTracks[index] };
|
|
try {
|
|
updateProgress(statusTarget, syncExisting + uploaded + failed, syncTotal, `${format.toUpperCase()} 다운로드`);
|
|
setStatus(statusTarget, `${index + 1}/${missingTracks.length} ${format.toUpperCase()} 처리 중: ${track.title || track.id}`);
|
|
await uploadTrackToCompanion(track, statusTarget, format);
|
|
uploaded += 1;
|
|
} catch (error) {
|
|
failed += 1;
|
|
console.warn("[ChatGPT to Suno] upload failed", track, error);
|
|
setStatus(statusTarget, `${track.title || track.id} 실패: ${error.message || error}`);
|
|
}
|
|
updateProgress(statusTarget, syncExisting + uploaded + failed, syncTotal, `${format.toUpperCase()} 다운로드`);
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
}
|
|
let healthText = "";
|
|
try {
|
|
healthText = ` / ${formatCompanionStatus(await readCompanionHealth())}`;
|
|
} catch {}
|
|
setStatus(statusTarget, `${format.toUpperCase()} 일괄 업로드 완료: ${uploaded}곡 성공, ${failed}곡 실패. ${syncTextSummary}. 로컬에서 메타데이터 후처리 중${healthText}`);
|
|
}
|
|
|
|
function trackOptionLabel(track, index) {
|
|
const title = track?.title || track?.id || "제목 없음";
|
|
const shortId = track?.id ? String(track.id).slice(0, 8) : "";
|
|
return `${index + 1}. ${title}${shortId ? ` [${shortId}]` : ""}`;
|
|
}
|
|
|
|
async function testCompanion(statusTarget) {
|
|
try {
|
|
const response = await fetch("http://127.0.0.1:17873/health", {
|
|
method: "GET",
|
|
cache: "no-store",
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
setStatus(statusTarget, `연결 실패: ${response.status} ${response.statusText}`);
|
|
return;
|
|
}
|
|
const data = tryParseJson(text) || {};
|
|
setStatus(statusTarget, `연결 OK: ${formatCompanionStatus(data)}`);
|
|
} catch (error) {
|
|
setStatus(statusTarget, `연결 실패: companion을 켜고 새로고침해줘. ${error.message || error}`);
|
|
}
|
|
}
|
|
|
|
async function readCompanionHealth() {
|
|
const response = await fetch("http://127.0.0.1:17873/health", {
|
|
method: "GET",
|
|
cache: "no-store",
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 160)}`);
|
|
}
|
|
return tryParseJson(text) || {};
|
|
}
|
|
|
|
async function stopCompanionWork(statusTarget) {
|
|
try {
|
|
const response = await fetch("http://127.0.0.1:17873/stop", {
|
|
method: "POST",
|
|
cache: "no-store",
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
setStatus(statusTarget, `중단 실패: ${response.status} ${response.statusText}`);
|
|
return;
|
|
}
|
|
const data = tryParseJson(text) || {};
|
|
setStatus(statusTarget, `작업 중단: 실행 ${data.stopped || 0}개 종료, 대기 ${data.dropped || 0}개 제거`);
|
|
} catch (error) {
|
|
setStatus(statusTarget, `중단 실패: companion 연결 확인 필요. ${error.message || error}`);
|
|
}
|
|
}
|
|
|
|
function formatCompanionStatus(data) {
|
|
if (!data || typeof data !== "object") return "상태 정보 없음";
|
|
const active = Number(data.activeCount || 0);
|
|
const queued = Number(data.queued || 0);
|
|
const done = Number(data.done ?? ((data.completed || 0) + (data.failed || 0)));
|
|
const total = Number(data.total || 0);
|
|
const failed = Number(data.failed || 0);
|
|
const percent = Number.isFinite(Number(data.percent)) ? Number(data.percent) : (total ? Math.round((done / total) * 1000) / 10 : 100);
|
|
const workerText = data.maxWorkers ? `${active}/${data.maxWorkers}` : `${active}`;
|
|
const title = data.active || "";
|
|
const titleText = title ? ` / 처리중: ${title}` : "";
|
|
return `작업 ${done}/${total} (${percent}%), 실행 ${workerText}, 대기 ${queued}, 실패 ${failed}${titleText}`;
|
|
}
|
|
|
|
async function bootSuno() {
|
|
const body = createPanel("Suno Prompt Paste", "cgs-suno");
|
|
const slots = await readSlots();
|
|
body.innerHTML = `
|
|
<div class="cgs-muted" data-cgs-summary></div>
|
|
<select class="cgs-select" data-cgs-slot>
|
|
${slots.map((payload, index) => `<option value="${index}">${escapeHtml(slotLabel(payload, index))}</option>`).join("")}
|
|
</select>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-fill>선택 슬롯 붙여넣기</button>
|
|
</div>
|
|
<div class="cgs-field-list">
|
|
${FIELD_DEFS.map((def) => `<button class="cgs-btn" type="button" data-cgs-copy="${def.key}">${escapeHtml(def.label)}</button>`).join("")}
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-scan-library>곡 감지</button>
|
|
</div>
|
|
<div class="cgs-muted" data-cgs-selection-summary></div>
|
|
<div class="cgs-muted" data-cgs-sync-summary></div>
|
|
<div class="cgs-progress-wrap">
|
|
<div class="cgs-progress-label" data-cgs-progress-label>진행 0/0</div>
|
|
<div class="cgs-progress"><div class="cgs-progress-fill" data-cgs-progress-fill></div></div>
|
|
</div>
|
|
<div class="cgs-track-list" data-cgs-track-list></div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-process-one-mp3>선택 1곡 MP3</button>
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-process-one-wav>선택 1곡 WAV</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-selected-mp3>선택항목 MP3</button>
|
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-selected-wav>선택항목 WAV</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-test-companion>연결 테스트</button>
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-stop-companion>작업 중단</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-copy-diagnostics>진단 복사</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-library-mp3>누적 전체 MP3</button>
|
|
</div>
|
|
<div class="cgs-row">
|
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-process-library-wav>누적 전체 WAV</button>
|
|
</div>
|
|
<div class="cgs-status"></div>
|
|
`;
|
|
|
|
const summary = body.querySelector("[data-cgs-summary]");
|
|
const selectionSummary = body.querySelector("[data-cgs-selection-summary]");
|
|
const slotSelect = body.querySelector("[data-cgs-slot]");
|
|
const trackList = body.querySelector("[data-cgs-track-list]");
|
|
let libraryTracks = [];
|
|
const libraryTrackMap = new Map();
|
|
const selectedTrackIds = new Set();
|
|
let lastSelectedIndex = -1;
|
|
let autoScanTimer = 0;
|
|
|
|
const getSelectedTracks = () => libraryTracks.filter((track) => selectedTrackIds.has(track.id));
|
|
const mergeLibraryTracks = (tracks) => {
|
|
for (const track of tracks) {
|
|
if (!track.id) continue;
|
|
if (libraryTrackMap.has(track.id)) {
|
|
libraryTrackMap.set(track.id, mergeTrack(libraryTrackMap.get(track.id), track));
|
|
} else {
|
|
libraryTrackMap.set(track.id, track);
|
|
}
|
|
}
|
|
libraryTracks = [...libraryTrackMap.values()].filter((track) => track?.id);
|
|
};
|
|
const renderLibraryTrackList = () => {
|
|
try {
|
|
for (const id of [...selectedTrackIds]) {
|
|
if (!libraryTrackMap.has(id)) selectedTrackIds.delete(id);
|
|
}
|
|
const selectedCount = selectedTrackIds.size;
|
|
selectionSummary.textContent = libraryTracks.length
|
|
? `누적 ${libraryTracks.length}곡 / 선택 ${selectedCount}곡`
|
|
: "누적된 곡 없음. 스크롤하면서 곡 감지를 눌러줘.";
|
|
|
|
const nodes = [];
|
|
if (!libraryTracks.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "cgs-track-item";
|
|
empty.innerHTML = `<div></div><div class="cgs-track-meta">감지된 곡 없음</div><div></div>`;
|
|
trackList.replaceChildren(empty);
|
|
return;
|
|
}
|
|
|
|
libraryTracks.forEach((track, index) => {
|
|
const row = document.createElement("div");
|
|
row.className = `cgs-track-item ${selectedTrackIds.has(track.id) ? "cgs-selected" : ""}`;
|
|
row.dataset.cgsTrackIndex = String(index);
|
|
row.title = track.id || "";
|
|
|
|
const number = document.createElement("div");
|
|
number.className = "cgs-track-index";
|
|
number.textContent = String(index + 1);
|
|
|
|
const text = document.createElement("div");
|
|
const title = document.createElement("div");
|
|
title.className = "cgs-track-title";
|
|
title.textContent = track.title || track.id || "제목 없음";
|
|
const meta = document.createElement("div");
|
|
meta.className = "cgs-track-meta";
|
|
meta.textContent = String(track.style || track.lyrics || track.id || "").slice(0, 110);
|
|
text.append(title, meta);
|
|
|
|
const badge = document.createElement("div");
|
|
badge.className = "cgs-track-badge";
|
|
badge.textContent = [
|
|
track.audioUrl ? "MP3" : "",
|
|
track.wavUrl ? "WAV" : "",
|
|
track.imageUrl ? "IMG" : "",
|
|
].filter(Boolean).join(" ") || "ID";
|
|
|
|
row.append(number, text, badge);
|
|
nodes.push(row);
|
|
});
|
|
trackList.replaceChildren(...nodes);
|
|
} catch (error) {
|
|
console.error("[ChatGPT to Suno] track list render failed", error);
|
|
setStatus(body, `곡 목록 표시 실패: ${error.message || error}`);
|
|
}
|
|
};
|
|
const setSelectedRange = (from, to) => {
|
|
const start = Math.max(0, Math.min(from, to));
|
|
const end = Math.min(libraryTracks.length - 1, Math.max(from, to));
|
|
for (let index = start; index <= end; index += 1) {
|
|
if (libraryTracks[index]?.id) selectedTrackIds.add(libraryTracks[index].id);
|
|
}
|
|
};
|
|
const scanAndMergeVisibleTracks = () => {
|
|
const scannedTracks = scanSunoLibraryTracks();
|
|
const before = libraryTracks.length;
|
|
mergeLibraryTracks(scannedTracks);
|
|
renderLibraryTrackList();
|
|
updateSummary();
|
|
return { scannedTracks, added: libraryTracks.length - before };
|
|
};
|
|
const updateSummary = () => {
|
|
const payload = slots[Number(slotSelect.value)];
|
|
const track = findCurrentSunoTrack();
|
|
const promptText = payload ? `${payload.version || "저장된 프롬프트"}: ${payload.title || "제목 없음"}` : "선택 슬롯 비어있음";
|
|
const libraryText = libraryTracks.length ? ` / 라이브러리 ${libraryTracks.length}곡` : "";
|
|
const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지";
|
|
summary.textContent = promptText + trackText + libraryText;
|
|
};
|
|
renderLibraryTrackList();
|
|
updateSummary();
|
|
slotSelect.addEventListener("change", updateSummary);
|
|
|
|
body.querySelector("[data-cgs-fill]").addEventListener("click", async () => {
|
|
const latest = await readPayload(Number(slotSelect.value));
|
|
if (!latest) {
|
|
setStatus(body, "선택 슬롯에 저장된 값이 없어.");
|
|
return;
|
|
}
|
|
const results = fillSuno(latest);
|
|
setStatus(body, results.join(" / "));
|
|
});
|
|
|
|
for (const button of body.querySelectorAll("[data-cgs-copy]")) {
|
|
button.addEventListener("click", async () => {
|
|
const latest = await readPayload(Number(slotSelect.value));
|
|
const key = button.getAttribute("data-cgs-copy");
|
|
const value = latest?.[key] || "";
|
|
if (!value) {
|
|
setStatus(body, "복사할 값이 없어.");
|
|
return;
|
|
}
|
|
GM_setClipboard(value, "text");
|
|
setStatus(body, `${button.textContent}만 복사했어.`);
|
|
});
|
|
}
|
|
|
|
body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => {
|
|
const { scannedTracks, added } = scanAndMergeVisibleTracks();
|
|
const sample = scannedTracks.slice(0, 3).map((track) => track.title || track.id).join(", ");
|
|
if (scannedTracks.length) {
|
|
const visibleCount = getVisibleLibraryRows().length;
|
|
setStatus(body, `이번 화면 ${scannedTracks.length}곡 감지, 누적 ${libraryTracks.length}곡 (+${added}): ${sample} / 화면 행 ${visibleCount}개 기준`);
|
|
return;
|
|
}
|
|
const visibleHints = visibleSunoRowsHint();
|
|
const networkHint = capturedSunoResponses.length ? `API 응답 ${capturedSunoResponses.length}개는 잡혔는데 clip 구조를 못 찾았어.` : "아직 SUNO API 응답을 못 잡았어.";
|
|
setStatus(body, `${networkHint} 목록 새로고침이나 스크롤 후 다시 눌러봐. 화면 텍스트: ${visibleHints.join(", ")}`);
|
|
});
|
|
|
|
const scheduleAutoScan = () => {
|
|
clearTimeout(autoScanTimer);
|
|
autoScanTimer = setTimeout(() => {
|
|
const result = scanAndMergeVisibleTracks();
|
|
if (result.added > 0) {
|
|
setStatus(body, `스크롤 감지로 ${result.added}곡 추가, 누적 ${libraryTracks.length}곡`);
|
|
}
|
|
}, 450);
|
|
};
|
|
window.addEventListener("scroll", scheduleAutoScan, { passive: true });
|
|
document.addEventListener("scroll", scheduleAutoScan, { passive: true, capture: true });
|
|
|
|
trackList.addEventListener("click", (event) => {
|
|
const item = event.target.closest("[data-cgs-track-index]");
|
|
if (!item) return;
|
|
const index = Number(item.getAttribute("data-cgs-track-index"));
|
|
const track = libraryTracks[index];
|
|
if (!track?.id) return;
|
|
if (event.shiftKey && lastSelectedIndex >= 0) {
|
|
setSelectedRange(lastSelectedIndex, index);
|
|
} else if (event.ctrlKey || event.metaKey) {
|
|
if (selectedTrackIds.has(track.id)) selectedTrackIds.delete(track.id);
|
|
else selectedTrackIds.add(track.id);
|
|
lastSelectedIndex = index;
|
|
} else {
|
|
selectedTrackIds.clear();
|
|
selectedTrackIds.add(track.id);
|
|
lastSelectedIndex = index;
|
|
}
|
|
renderLibraryTrackList();
|
|
updateSummary();
|
|
});
|
|
|
|
const processSelectedTrack = async (format) => {
|
|
if (!libraryTracks.length) {
|
|
mergeLibraryTracks(scanSunoLibraryTracks());
|
|
renderLibraryTrackList();
|
|
}
|
|
updateSummary();
|
|
const track = getSelectedTracks()[0] || libraryTracks[0];
|
|
if (!track) {
|
|
setStatus(body, "선택할 감지 곡이 없어. 곡 감지를 먼저 눌러줘.");
|
|
return;
|
|
}
|
|
try {
|
|
const data = await uploadTrackToCompanion(track, body, format);
|
|
setStatus(body, `선택 1곡 ${format.toUpperCase()} 업로드 완료: ${track.title || track.id} / ${formatCompanionStatus(data)}`);
|
|
} catch (error) {
|
|
setStatus(body, `선택 1곡 ${format.toUpperCase()} 처리 실패: ${error.message || error}`);
|
|
}
|
|
};
|
|
|
|
body.querySelector("[data-cgs-process-one-mp3]").addEventListener("click", async () => {
|
|
await processSelectedTrack("mp3");
|
|
});
|
|
|
|
body.querySelector("[data-cgs-process-one-wav]").addEventListener("click", async () => {
|
|
await processSelectedTrack("wav");
|
|
});
|
|
|
|
body.querySelector("[data-cgs-process-selected-mp3]").addEventListener("click", async () => {
|
|
await processSelectedTracks("mp3");
|
|
});
|
|
|
|
body.querySelector("[data-cgs-process-selected-wav]").addEventListener("click", async () => {
|
|
await processSelectedTracks("wav");
|
|
});
|
|
|
|
const processLibraryTracks = async (format) => {
|
|
if (!libraryTracks.length) {
|
|
mergeLibraryTracks(scanSunoLibraryTracks());
|
|
renderLibraryTrackList();
|
|
}
|
|
updateSummary();
|
|
try {
|
|
await uploadTracksToCompanion(libraryTracks, body, format);
|
|
} catch (error) {
|
|
setStatus(body, `${format.toUpperCase()} 일괄 처리 실패: ${error.message || error}`);
|
|
}
|
|
};
|
|
|
|
const processSelectedTracks = async (format) => {
|
|
if (!libraryTracks.length) {
|
|
mergeLibraryTracks(scanSunoLibraryTracks());
|
|
renderLibraryTrackList();
|
|
}
|
|
const tracks = getSelectedTracks();
|
|
if (!tracks.length) {
|
|
setStatus(body, "선택된 곡이 없어. 목록에서 Ctrl/Shift로 고른 뒤 눌러줘.");
|
|
return;
|
|
}
|
|
updateSummary();
|
|
try {
|
|
await uploadTracksToCompanion(tracks, body, format);
|
|
} catch (error) {
|
|
setStatus(body, `선택항목 ${format.toUpperCase()} 처리 실패: ${error.message || error}`);
|
|
}
|
|
};
|
|
|
|
body.querySelector("[data-cgs-process-library-mp3]").addEventListener("click", async () => {
|
|
await processLibraryTracks("mp3");
|
|
});
|
|
|
|
body.querySelector("[data-cgs-process-library-wav]").addEventListener("click", async () => {
|
|
await processLibraryTracks("wav");
|
|
});
|
|
|
|
body.querySelector("[data-cgs-test-companion]").addEventListener("click", async () => {
|
|
await testCompanion(body);
|
|
});
|
|
|
|
body.querySelector("[data-cgs-stop-companion]").addEventListener("click", async () => {
|
|
await stopCompanionWork(body);
|
|
});
|
|
|
|
body.querySelector("[data-cgs-copy-diagnostics]").addEventListener("click", async () => {
|
|
if (!libraryTracks.length) {
|
|
mergeLibraryTracks(scanSunoLibraryTracks());
|
|
renderLibraryTrackList();
|
|
}
|
|
const selectedTracks = getSelectedTracks();
|
|
const selectedTrack = selectedTracks[0] || libraryTracks[0];
|
|
let health = {};
|
|
let healthError = "";
|
|
try {
|
|
health = await readCompanionHealth();
|
|
} catch (error) {
|
|
healthError = error.message || String(error);
|
|
}
|
|
const payload = {
|
|
scriptVersion: SCRIPT_VERSION,
|
|
location: location.href,
|
|
detectedTracks: libraryTracks.length,
|
|
visibleRows: getVisibleLibraryRows().length,
|
|
selectedCount: selectedTracks.length,
|
|
selectedIds: selectedTracks.map((track) => track.id),
|
|
selectedTrack: selectedTrack ? {
|
|
id: selectedTrack.id || "",
|
|
title: selectedTrack.title || "",
|
|
audioUrl: selectedTrack.audioUrl || "",
|
|
hasWavUrl: Boolean(selectedTrack.wavUrl),
|
|
hasImageUrl: Boolean(selectedTrack.imageUrl),
|
|
hasLyrics: Boolean(selectedTrack.lyrics),
|
|
mp3Candidates: getMp3Candidates(selectedTrack).slice(0, 10),
|
|
} : null,
|
|
capturedSunoResponses: capturedSunoResponses.length,
|
|
companion: healthError ? { ok: false, error: healthError } : health,
|
|
};
|
|
GM_setClipboard(JSON.stringify(payload, null, 2), "text");
|
|
setStatus(body, `진단 복사 완료: v${SCRIPT_VERSION}, 감지 ${libraryTracks.length}곡, 로컬 ${healthError ? "실패" : "OK"}`);
|
|
});
|
|
}
|
|
|
|
function safeBootSuno() {
|
|
try {
|
|
bootSuno();
|
|
} catch (error) {
|
|
console.error("[ChatGPT to Suno]", error);
|
|
createErrorPanel(error);
|
|
}
|
|
}
|
|
|
|
function safeBootChatGPT() {
|
|
try {
|
|
const versions = parseVersions(getConversationText());
|
|
if (versions.length) bootChatGPT();
|
|
} catch (error) {
|
|
console.error("[ChatGPT to Suno]", error);
|
|
createErrorPanel(error);
|
|
}
|
|
}
|
|
|
|
function startApp() {
|
|
if (!document.body) {
|
|
setTimeout(startApp, 100);
|
|
return;
|
|
}
|
|
|
|
if (isSuno) safeBootSuno();
|
|
if (isChatGPT) safeBootChatGPT();
|
|
|
|
setInterval(() => {
|
|
if (isSuno && !document.querySelector("#cgs-panel")) {
|
|
safeBootSuno();
|
|
}
|
|
if (isChatGPT) {
|
|
const hasPanel = Boolean(document.querySelector("#cgs-panel"));
|
|
const hasVersions = parseVersions(getConversationText()).length > 0;
|
|
if (hasVersions && !hasPanel) safeBootChatGPT();
|
|
if (!hasVersions && hasPanel) document.querySelector("#cgs-panel")?.remove();
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
startApp();
|
|
})();
|