Add Suno library batch download
This commit is contained in:
@@ -35,8 +35,11 @@ SUNO 화면 구조가 바뀌어 자동 입력이 실패하면, 패널의 `Title`
|
|||||||
|
|
||||||
SUNO 곡 상세/플레이어 화면에서 `WAV + 커버 + 메타 다운로드`를 누르면 현재 페이지에서 감지된 곡 정보를 기준으로 아래 파일을 다운로드합니다.
|
SUNO 곡 상세/플레이어 화면에서 `WAV + 커버 + 메타 다운로드`를 누르면 현재 페이지에서 감지된 곡 정보를 기준으로 아래 파일을 다운로드합니다.
|
||||||
|
|
||||||
|
SUNO 라이브러리 목록 화면에서는 `라이브러리 곡 감지`를 누른 뒤 `감지된 라이브러리 일괄 다운로드`를 누릅니다. 현재 브라우저에 로드된 곡 목록을 기준으로 처리하므로, 무한 스크롤 목록에서는 먼저 아래쪽까지 스크롤해서 곡을 로드해야 합니다.
|
||||||
|
|
||||||
- `{title}.wav`: clip id가 감지되면 `https://cdn1.suno.ai/{clipId}.wav` 후보를 내려받습니다.
|
- `{title}.wav`: clip id가 감지되면 `https://cdn1.suno.ai/{clipId}.wav` 후보를 내려받습니다.
|
||||||
- `{title}.cover.*`: 페이지에서 감지된 앨범아트 URL을 내려받습니다.
|
- `{title}.cover.*`: 페이지에서 감지된 앨범아트 URL을 내려받습니다.
|
||||||
- `{title}.metadata.json`: title, clip id, audio/image URL, 감지된 원본 메타데이터, 현재 페이지 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이 잡혔는지 확인할 수 있습니다.
|
WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책에 따라 실패할 수 있습니다. 이 경우 metadata JSON의 `detectedAudioUrls`, `detectedImageUrls`, `detectedIds`를 보고 실제 페이지에서 어떤 URL이 잡혔는지 확인할 수 있습니다.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name ChatGPT to Suno Prompt Copier
|
// @name ChatGPT to Suno Prompt Copier
|
||||||
// @namespace local.suno-helper
|
// @namespace local.suno-helper
|
||||||
// @version 0.2.0
|
// @version 0.3.0
|
||||||
// @description Copy structured ChatGPT song prompt versions into Suno fields.
|
// @description Copy structured ChatGPT song prompt versions into Suno fields.
|
||||||
// @author local
|
// @author local
|
||||||
// @match https://chatgpt.com/*
|
// @match https://chatgpt.com/*
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
// @match https://*.suno.com/*
|
// @match https://*.suno.com/*
|
||||||
// @updateURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js
|
// @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
|
// @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
|
// @run-at document-idle
|
||||||
// @grant GM_addStyle
|
// @grant GM_addStyle
|
||||||
// @grant GM_getValue
|
// @grant GM_getValue
|
||||||
@@ -495,6 +497,84 @@
|
|||||||
return 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", "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() {
|
function findCurrentSunoTrack() {
|
||||||
const pageText = document.body.innerText || "";
|
const pageText = document.body.innerText || "";
|
||||||
const htmlText = document.documentElement.innerHTML || "";
|
const htmlText = document.documentElement.innerHTML || "";
|
||||||
@@ -595,6 +675,30 @@
|
|||||||
setStatus(statusTarget, messages.join(" / "));
|
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() {
|
async function bootSuno() {
|
||||||
const body = createPanel("Suno Prompt Paste");
|
const body = createPanel("Suno Prompt Paste");
|
||||||
const slots = await readSlots();
|
const slots = await readSlots();
|
||||||
@@ -615,17 +719,25 @@
|
|||||||
<div class="cgs-row">
|
<div class="cgs-row">
|
||||||
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-track>WAV + 커버 + 메타 다운로드</button>
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-track>WAV + 커버 + 메타 다운로드</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cgs-row">
|
||||||
|
<button class="cgs-btn cgs-wide" type="button" data-cgs-scan-library>라이브러리 곡 감지</button>
|
||||||
|
</div>
|
||||||
|
<div class="cgs-row">
|
||||||
|
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-library>감지된 라이브러리 일괄 다운로드</button>
|
||||||
|
</div>
|
||||||
<div class="cgs-status"></div>
|
<div class="cgs-status"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const summary = body.querySelector("[data-cgs-summary]");
|
const summary = body.querySelector("[data-cgs-summary]");
|
||||||
const slotSelect = body.querySelector("[data-cgs-slot]");
|
const slotSelect = body.querySelector("[data-cgs-slot]");
|
||||||
|
let libraryTracks = [];
|
||||||
const updateSummary = () => {
|
const updateSummary = () => {
|
||||||
const payload = slots[Number(slotSelect.value)];
|
const payload = slots[Number(slotSelect.value)];
|
||||||
const track = findCurrentSunoTrack();
|
const track = findCurrentSunoTrack();
|
||||||
const promptText = payload ? `${payload.version || "저장된 프롬프트"}: ${payload.title || "제목 없음"}` : "선택 슬롯 비어있음";
|
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}` : " / 곡 미감지";
|
const trackText = track.id || track.audioUrl || track.imageUrl ? ` / 곡 감지: ${track.title || track.id}` : " / 곡 미감지";
|
||||||
summary.textContent = promptText + trackText;
|
summary.textContent = promptText + trackText + libraryText;
|
||||||
};
|
};
|
||||||
updateSummary();
|
updateSummary();
|
||||||
slotSelect.addEventListener("change", updateSummary);
|
slotSelect.addEventListener("change", updateSummary);
|
||||||
@@ -668,6 +780,19 @@
|
|||||||
}
|
}
|
||||||
downloadSunoPackage(track, body);
|
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() {
|
function startApp() {
|
||||||
|
|||||||
Reference in New Issue
Block a user