diff --git a/README.md b/README.md index 7d7f556..8731b41 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,11 @@ SUNO 화면 구조가 바뀌어 자동 입력이 실패하면, 패널의 `Title` SUNO 곡 상세/플레이어 화면에서 `WAV + 커버 + 메타 다운로드`를 누르면 현재 페이지에서 감지된 곡 정보를 기준으로 아래 파일을 다운로드합니다. +SUNO 라이브러리 목록 화면에서는 `라이브러리 곡 감지`를 누른 뒤 `감지된 라이브러리 일괄 다운로드`를 누릅니다. 현재 브라우저에 로드된 곡 목록을 기준으로 처리하므로, 무한 스크롤 목록에서는 먼저 아래쪽까지 스크롤해서 곡을 로드해야 합니다. + - `{title}.wav`: clip id가 감지되면 `https://cdn1.suno.ai/{clipId}.wav` 후보를 내려받습니다. - `{title}.cover.*`: 페이지에서 감지된 앨범아트 URL을 내려받습니다. - `{title}.metadata.json`: title, clip id, audio/image URL, 감지된 원본 메타데이터, 현재 페이지 URL을 저장합니다. +- `suno-library-{date}.manifest.json`: 일괄 다운로드 시 감지된 전체 곡 목록을 저장합니다. WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책에 따라 실패할 수 있습니다. 이 경우 metadata JSON의 `detectedAudioUrls`, `detectedImageUrls`, `detectedIds`를 보고 실제 페이지에서 어떤 URL이 잡혔는지 확인할 수 있습니다. diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js index 78f6836..dc5e189 100644 --- a/chatgpt-suno-tampermonkey.user.js +++ b/chatgpt-suno-tampermonkey.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name ChatGPT to Suno Prompt Copier // @namespace local.suno-helper -// @version 0.2.0 +// @version 0.3.0 // @description Copy structured ChatGPT song prompt versions into Suno fields. // @author local // @match https://chatgpt.com/* @@ -10,6 +10,8 @@ // @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 cdn*.suno.ai +// @connect *.suno.com // @run-at document-idle // @grant GM_addStyle // @grant GM_getValue @@ -495,6 +497,84 @@ 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", "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 audioUrl = firstValue(object, ["audio_url", "audioUrl", "play_url", "playUrl", "mp3_url", "mp3Url"]); + const wavUrl = firstValue(object, ["wav_url", "wavUrl"]) || (id ? `https://cdn1.suno.ai/${id}.wav` : ""); + return { + id, + title, + audioUrl, + wavUrl, + imageUrl, + pageUrl: location.href, + metadata: object, + capturedAt: new Date().toISOString(), + }; + } + + function mergeTrack(target, source) { + if (!target) return source; + for (const key of ["title", "audioUrl", "wavUrl", "imageUrl", "pageUrl"]) { + if (!target[key] && source[key]) target[key] = source[key]; + } + target.metadata = target.metadata && Object.keys(target.metadata).length ? target.metadata : source.metadata; + return target; + } + + function scanSunoLibraryTracks() { + const scripts = readJsonScripts(); + const objects = scripts.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: `https://cdn1.suno.ai/${id}.wav`, + imageUrl: "", + pageUrl: location.href, + metadata: {}, + capturedAt: new Date().toISOString(), + }); + } + + return [...tracks.values()] + .filter((track) => track.id || track.audioUrl || track.imageUrl) + .sort((a, b) => String(a.title).localeCompare(String(b.title), "ko")); + } + function findCurrentSunoTrack() { const pageText = document.body.innerText || ""; const htmlText = document.documentElement.innerHTML || ""; @@ -595,6 +675,30 @@ 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}`); + downloadSunoPackage(track, statusTarget); + await new Promise((resolve) => setTimeout(resolve, 650)); + } + setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`); + } + async function bootSuno() { const body = createPanel("Suno Prompt Paste"); const slots = await readSlots(); @@ -615,17 +719,25 @@
+
+ +
+
+ +
`; const summary = body.querySelector("[data-cgs-summary]"); const slotSelect = body.querySelector("[data-cgs-slot]"); + let libraryTracks = []; 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; + summary.textContent = promptText + trackText + libraryText; }; updateSummary(); slotSelect.addEventListener("change", updateSummary); @@ -668,6 +780,19 @@ } downloadSunoPackage(track, body); }); + + body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => { + libraryTracks = scanSunoLibraryTracks(); + updateSummary(); + const sample = libraryTracks.slice(0, 3).map((track) => track.title || track.id).join(", "); + setStatus(body, libraryTracks.length ? `${libraryTracks.length}곡 감지: ${sample}` : "라이브러리 곡을 못 찾았어. 곡 목록을 스크롤해서 로드한 뒤 다시 눌러봐."); + }); + + body.querySelector("[data-cgs-download-library]").addEventListener("click", async () => { + if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks(); + updateSummary(); + await downloadSunoBatch(libraryTracks, body); + }); } function startApp() {