Prepare Suno WAV URLs through studio API

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-01 18:03:52 +09:00
parent a7a54d3532
commit cec4ae3d0c
3 changed files with 120 additions and 23 deletions
+2 -2
View File
@@ -49,7 +49,7 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
브라우저는 `\\VaultOfData\AKAMedia\음악\처리됨\SUNO` 같은 로컬/NAS 경로에 직접 파일을 정리하거나 WAV를 FLAC으로 인코딩할 수 없습니다. 그래서 Tampermonkey는 manifest를 만들고, 로컬 PowerShell 스크립트가 실제 다운로드/변환/정리를 담당합니다. 브라우저는 `\\VaultOfData\AKAMedia\음악\처리됨\SUNO` 같은 로컬/NAS 경로에 직접 파일을 정리하거나 WAV를 FLAC으로 인코딩할 수 없습니다. 그래서 Tampermonkey는 manifest를 만들고, 로컬 PowerShell 스크립트가 실제 다운로드/변환/정리를 담당합니다.
1. SUNO 라이브러리에서 `라이브러리 곡 감지`를 누릅니다. 1. SUNO 라이브러리에서 `라이브러리 곡 감지`를 누릅니다.
2. `처리용 manifest 저장`을 눌러 Downloads 폴더에 `suno-library-YYYY-MM-DD.manifest.json`을 저장합니다. 2. `처리용 manifest 저장`을 눌러 Downloads 폴더에 `suno-library-YYYY-MM-DD.manifest.json`을 저장합니다. 이 단계에서 브라우저가 SUNO의 `convert_wav/``wav_file/` API를 호출해 WAV URL을 준비합니다.
3. `run_process_suno_library.ps1`을 실행합니다. 3. `run_process_suno_library.ps1`을 실행합니다.
기본 출력 위치: 기본 출력 위치:
@@ -58,4 +58,4 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
\\VaultOfData\AKAMedia\음악\처리됨\SUNO\YYYY-MM-DD \\VaultOfData\AKAMedia\음악\처리됨\SUNO\YYYY-MM-DD
``` ```
로컬 처리기는 각 곡의 WAV 후보를 다운로드하고, 실패하면 manifest의 audio URL로 fallback합니다. 이후 ffmpeg로 FLAC 변환하면서 title, artist, album, genre/style, lyrics, Suno clip id, cover art를 메타데이터로 넣습니다. 로컬 처리기는 manifest에 저장된 WAV URL을 다운로드하고, 실패하면 manifest의 audio URL로 fallback합니다. 이후 ffmpeg로 FLAC 변환하면서 title, artist, album, genre/style, lyrics, Suno clip id, cover art를 메타데이터로 넣습니다.
+95 -18
View File
@@ -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.4.2 // @version 0.4.3
// @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/*
@@ -12,6 +12,7 @@
// @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 cdn*.suno.ai
// @connect *.suno.com // @connect *.suno.com
// @connect studio-api-prod.suno.com
// @run-at document-start // @run-at document-start
// @grant GM_addStyle // @grant GM_addStyle
// @grant GM_getValue // @grant GM_getValue
@@ -42,7 +43,7 @@
function rememberSunoResponse(url, data) { function rememberSunoResponse(url, data) {
if (!isSuno || !data) return; if (!isSuno || !data) return;
const text = typeof data === "string" ? data : JSON.stringify(data); const text = typeof data === "string" ? data : JSON.stringify(data);
if (!/clip|audio_url|image_url|song|playlist|metadata|cdn[0-9]*\.suno\.ai/i.test(text)) return; if (!/clip|audio|image_url|song|playlist|metadata|cdn[0-9]*\.suno\.ai|download/i.test(text)) return;
capturedSunoResponses.push({ capturedSunoResponses.push({
url: String(url || location.href), url: String(url || location.href),
data, data,
@@ -529,6 +530,15 @@
return (match?.[1] || fallback).toLowerCase(); 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) { function unique(values) {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
@@ -581,7 +591,7 @@
if (!object || typeof object !== "object" || Array.isArray(object)) return false; if (!object || typeof object !== "object" || Array.isArray(object)) return false;
const id = firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]); const id = firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]);
const hasTrackText = firstValue(object, ["title", "name", "prompt", "gpt_description_prompt", "metadata"]); 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"]); 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)); return Boolean(id && (hasTrackText || hasMedia));
} }
@@ -589,8 +599,10 @@
const id = String(firstValue(object, ["id", "clip_id", "clipId", "audio_id", "audioId"]) || "").trim(); 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 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 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 audioPath = firstValue(object, ["audio_url", "audioUrl", "stream_audio_url", "streamAudioUrl", "play_url", "playUrl", "play_path", "playPath", "mp3_url", "mp3Url", "video_url", "videoUrl"]);
const wavUrl = firstValue(object, ["wav_url", "wavUrl"]) || (id ? `https://cdn1.suno.ai/${id}.wav` : ""); const audioUrl = normalizeSunoMediaUrl(audioPath);
const wavPath = firstValue(object, ["wav_url", "wavUrl", "download_url", "downloadUrl"]);
const wavUrl = normalizeSunoMediaUrl(wavPath) || (id ? `https://cdn1.suno.ai/${id}.wav` : "");
const lyrics = firstValue(object, ["prompt", "lyrics", "lyric", "text"]); const lyrics = firstValue(object, ["prompt", "lyrics", "lyric", "text"]);
const style = firstValue(object, ["gpt_description_prompt", "style", "tags", "description"]); const style = firstValue(object, ["gpt_description_prompt", "style", "tags", "description"]);
const model = firstValue(object, ["model_name", "model", "major_model_version"]); const model = firstValue(object, ["model_name", "model", "major_model_version"]);
@@ -787,7 +799,7 @@
}; };
} }
function downloadUrl(url, name, onerror) { function downloadUrl(url, name, onerror, onload) {
if (!url) { if (!url) {
onerror?.("URL 없음"); onerror?.("URL 없음");
return; return;
@@ -796,10 +808,60 @@
url, url,
name, name,
saveAs: false, saveAs: false,
onload: () => onload?.(),
onerror: (error) => onerror?.(error?.error || String(error)), onerror: (error) => onerror?.(error?.error || String(error)),
}); });
} }
async function callSunoJson(url, options = {}) {
const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
const response = await page.fetch(url, {
credentials: "include",
...options,
headers: {
"accept": "application/json, text/plain, */*",
...(options.method && options.method !== "GET" ? { "content-type": "application/json" } : {}),
...(options.headers || {}),
},
});
const text = await 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 requestSunoWavUrl(track) {
if (!track.id) return track.wavUrl || "";
const base = `https://studio-api-prod.suno.com/api/gen/${encodeURIComponent(track.id)}`;
try {
await callSunoJson(`${base}/convert_wav/`, { method: "POST" });
} catch (error) {
console.warn("[ChatGPT to Suno] convert_wav failed", error);
}
for (let attempt = 0; attempt < 8; attempt += 1) {
try {
const data = await callSunoJson(`${base}/wav_file/`, { method: "GET" });
const direct =
data?.url ||
data?.download_url ||
data?.wav_url ||
data?.audio_url ||
data?.file ||
(typeof data === "string" && /^https?:\/\//i.test(data) ? data : "");
if (direct) return normalizeSunoMediaUrl(direct);
} catch (error) {
console.warn("[ChatGPT to Suno] wav_file failed", error);
}
await new Promise((resolve) => setTimeout(resolve, 1300));
}
return track.wavUrl || `https://cdn1.suno.ai/${track.id}.wav`;
}
function downloadTextFile(text, name) { function downloadTextFile(text, name) {
const blob = new Blob([text], { type: "application/json;charset=utf-8" }); const blob = new Blob([text], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -812,7 +874,7 @@
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => URL.revokeObjectURL(url), 1000);
} }
function downloadSunoPackage(track, statusTarget) { async function downloadSunoPackage(track, statusTarget) {
const baseName = safeFilename(track.title || track.id); const baseName = safeFilename(track.title || track.id);
const messages = []; const messages = [];
const fail = (label) => (reason) => { const fail = (label) => (reason) => {
@@ -827,8 +889,16 @@
downloadUrl(track.imageUrl, `${baseName}.cover.${extensionFromUrl(track.imageUrl, "jpg")}`, fail("cover")); downloadUrl(track.imageUrl, `${baseName}.cover.${extensionFromUrl(track.imageUrl, "jpg")}`, fail("cover"));
messages.push("cover 요청"); messages.push("cover 요청");
} }
if (track.wavUrl) { const preparedWavUrl = await requestSunoWavUrl(track);
downloadUrl(track.wavUrl, `${baseName}.wav`, fail("wav")); if (preparedWavUrl) {
downloadUrl(preparedWavUrl, `${baseName}.wav`, (reason) => {
fail("wav")(reason);
if (track.audioUrl && track.audioUrl !== preparedWavUrl) {
downloadUrl(track.audioUrl, `${baseName}.${extensionFromUrl(track.audioUrl, "audio")}`, fail("audio-fallback"));
messages.push("audio fallback 요청");
setStatus(statusTarget, messages.join(" / "));
}
});
messages.push("wav 요청"); messages.push("wav 요청");
} else if (track.audioUrl) { } else if (track.audioUrl) {
downloadUrl(track.audioUrl, `${baseName}.${extensionFromUrl(track.audioUrl, "audio")}`, fail("audio")); downloadUrl(track.audioUrl, `${baseName}.${extensionFromUrl(track.audioUrl, "audio")}`, fail("audio"));
@@ -855,25 +925,32 @@
for (let index = 0; index < tracks.length; index += 1) { for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index]; const track = tracks[index];
setStatus(statusTarget, `${index + 1}/${tracks.length} 요청 중: ${track.title || track.id}`); setStatus(statusTarget, `${index + 1}/${tracks.length} 요청 중: ${track.title || track.id}`);
downloadSunoPackage(track, statusTarget); await downloadSunoPackage(track, statusTarget);
await new Promise((resolve) => setTimeout(resolve, 650)); await new Promise((resolve) => setTimeout(resolve, 650));
} }
setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`); setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`);
} }
function exportSunoManifest(tracks, statusTarget) { async function exportSunoManifest(tracks, statusTarget) {
if (!tracks.length) { if (!tracks.length) {
setStatus(statusTarget, "내보낼 곡을 못 찾았어. 라이브러리 곡 감지를 먼저 해줘."); setStatus(statusTarget, "내보낼 곡을 못 찾았어. 라이브러리 곡 감지를 먼저 해줘.");
return; 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);
preparedTracks.push(track);
}
const manifest = { const manifest = {
pageUrl: location.href, pageUrl: location.href,
capturedAt: new Date().toISOString(), capturedAt: new Date().toISOString(),
count: tracks.length, count: preparedTracks.length,
tracks, tracks: preparedTracks,
}; };
downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`); downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`);
setStatus(statusTarget, `${tracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`); setStatus(statusTarget, `${preparedTracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`);
} }
async function bootSuno() { async function bootSuno() {
@@ -952,13 +1029,13 @@
setStatus(body, track.id || track.audioUrl ? `감지됨: ${track.title || track.id}` : "현재 화면에서 곡 URL을 못 찾았어."); setStatus(body, track.id || track.audioUrl ? `감지됨: ${track.title || track.id}` : "현재 화면에서 곡 URL을 못 찾았어.");
}); });
body.querySelector("[data-cgs-download-track]").addEventListener("click", () => { body.querySelector("[data-cgs-download-track]").addEventListener("click", async () => {
const track = findCurrentSunoTrack(); const track = findCurrentSunoTrack();
if (!track.id && !track.audioUrl && !track.imageUrl) { if (!track.id && !track.audioUrl && !track.imageUrl) {
setStatus(body, "다운로드할 곡 정보를 못 찾았어. 곡 상세/플레이어 화면에서 다시 눌러봐."); setStatus(body, "다운로드할 곡 정보를 못 찾았어. 곡 상세/플레이어 화면에서 다시 눌러봐.");
return; return;
} }
downloadSunoPackage(track, body); await downloadSunoPackage(track, body);
}); });
body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => { body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => {
@@ -981,10 +1058,10 @@
await downloadSunoBatch(libraryTracks, body); await downloadSunoBatch(libraryTracks, body);
}); });
body.querySelector("[data-cgs-export-library]").addEventListener("click", () => { body.querySelector("[data-cgs-export-library]").addEventListener("click", async () => {
if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks(); if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks();
updateSummary(); updateSummary();
exportSunoManifest(libraryTracks, body); await exportSunoManifest(libraryTracks, body);
}); });
} }
+23 -3
View File
@@ -112,6 +112,17 @@ function Get-UrlExtension {
return $Fallback return $Fallback
} }
function Resolve-SunoMediaUrl {
param([string]$Value)
if (-not $Value) { return "" }
$raw = $Value.Trim()
if ($raw -match '^https?://') { return $raw }
if ($raw.StartsWith("//")) { return "https:$raw" }
if ($raw.StartsWith("/")) { return "https://suno.com$raw" }
return $raw
}
function Invoke-Download { function Invoke-Download {
param( param(
[string]$Url, [string]$Url,
@@ -180,8 +191,12 @@ foreach ($track in $tracks) {
$model = Get-DeepValue $track @("model_name", "model", "major_model_version") $model = Get-DeepValue $track @("model_name", "model", "major_model_version")
$createdAt = Get-DeepValue $track @("created_at", "createdAt") $createdAt = Get-DeepValue $track @("created_at", "createdAt")
$wavUrl = if ($track.wavUrl) { "$($track.wavUrl)" } elseif ($id) { "https://cdn1.suno.ai/$id.wav" } else { "" } $audioUrl = Resolve-SunoMediaUrl $(if ($track.audioUrl) { "$($track.audioUrl)" } else { Get-DeepValue $track @("audio_url", "audioUrl", "stream_audio_url", "streamAudioUrl", "play_url", "playUrl", "play_path", "playPath", "mp3_url", "mp3Url", "video_url", "videoUrl") })
$audioUrl = if ($track.audioUrl) { "$($track.audioUrl)" } else { "" } $wavUrl = Resolve-SunoMediaUrl $(if ($track.wavUrl) { "$($track.wavUrl)" } else { Get-DeepValue $track @("wav_url", "wavUrl", "download_url", "downloadUrl") })
$wavCandidates = @()
if ($wavUrl) { $wavCandidates += $wavUrl }
if ($id) { $wavCandidates += "https://cdn1.suno.ai/$id.wav" }
$wavCandidates = $wavCandidates | Where-Object { $_ } | Select-Object -Unique
$imageUrl = if ($track.imageUrl) { "$($track.imageUrl)" } else { Get-DeepValue $track @("image_url", "imageUrl", "image_large_url", "cover_url") } $imageUrl = if ($track.imageUrl) { "$($track.imageUrl)" } else { Get-DeepValue $track @("image_url", "imageUrl", "image_large_url", "cover_url") }
$inputAudio = Join-Path $workRoot "$prefix.input.wav" $inputAudio = Join-Path $workRoot "$prefix.input.wav"
@@ -193,7 +208,12 @@ foreach ($track in $tracks) {
Write-Host "" Write-Host ""
Write-Host "[$index/$($tracks.Count)] $title" Write-Host "[$index/$($tracks.Count)] $title"
$audioOk = Invoke-Download $wavUrl $inputAudio $audioOk = $false
foreach ($candidate in $wavCandidates) {
Write-Host "WAV 후보 시도: $candidate"
$audioOk = Invoke-Download $candidate $inputAudio
if ($audioOk) { break }
}
if (-not $audioOk -and $audioUrl) { if (-not $audioOk -and $audioUrl) {
Write-Host "WAV 후보 실패. audioUrl로 fallback 시도." Write-Host "WAV 후보 실패. audioUrl로 fallback 시도."
$audioOk = Invoke-Download $audioUrl $fallbackAudio $audioOk = Invoke-Download $audioUrl $fallbackAudio