Prepare Suno WAV URLs through studio API
This commit is contained in:
@@ -49,7 +49,7 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
|
||||
브라우저는 `\\VaultOfData\AKAMedia\음악\처리됨\SUNO` 같은 로컬/NAS 경로에 직접 파일을 정리하거나 WAV를 FLAC으로 인코딩할 수 없습니다. 그래서 Tampermonkey는 manifest를 만들고, 로컬 PowerShell 스크립트가 실제 다운로드/변환/정리를 담당합니다.
|
||||
|
||||
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`을 실행합니다.
|
||||
|
||||
기본 출력 위치:
|
||||
@@ -58,4 +58,4 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
|
||||
\\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를 메타데이터로 넣습니다.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name ChatGPT to Suno Prompt Copier
|
||||
// @namespace local.suno-helper
|
||||
// @version 0.4.2
|
||||
// @version 0.4.3
|
||||
// @description Copy structured ChatGPT song prompt versions into Suno fields.
|
||||
// @author local
|
||||
// @match https://chatgpt.com/*
|
||||
@@ -12,6 +12,7 @@
|
||||
// @downloadURL https://git.mokaya.org/shkim/suno-helper/raw/branch/master/chatgpt-suno-tampermonkey.user.js
|
||||
// @connect cdn*.suno.ai
|
||||
// @connect *.suno.com
|
||||
// @connect studio-api-prod.suno.com
|
||||
// @run-at document-start
|
||||
// @grant GM_addStyle
|
||||
// @grant GM_getValue
|
||||
@@ -42,7 +43,7 @@
|
||||
function rememberSunoResponse(url, data) {
|
||||
if (!isSuno || !data) return;
|
||||
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({
|
||||
url: String(url || location.href),
|
||||
data,
|
||||
@@ -529,6 +530,15 @@
|
||||
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))];
|
||||
}
|
||||
@@ -581,7 +591,7 @@
|
||||
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"]);
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -589,8 +599,10 @@
|
||||
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` : "");
|
||||
const audioPath = firstValue(object, ["audio_url", "audioUrl", "stream_audio_url", "streamAudioUrl", "play_url", "playUrl", "play_path", "playPath", "mp3_url", "mp3Url", "video_url", "videoUrl"]);
|
||||
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 style = firstValue(object, ["gpt_description_prompt", "style", "tags", "description"]);
|
||||
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) {
|
||||
onerror?.("URL 없음");
|
||||
return;
|
||||
@@ -796,10 +808,60 @@
|
||||
url,
|
||||
name,
|
||||
saveAs: false,
|
||||
onload: () => onload?.(),
|
||||
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) {
|
||||
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -812,7 +874,7 @@
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function downloadSunoPackage(track, statusTarget) {
|
||||
async function downloadSunoPackage(track, statusTarget) {
|
||||
const baseName = safeFilename(track.title || track.id);
|
||||
const messages = [];
|
||||
const fail = (label) => (reason) => {
|
||||
@@ -827,8 +889,16 @@
|
||||
downloadUrl(track.imageUrl, `${baseName}.cover.${extensionFromUrl(track.imageUrl, "jpg")}`, fail("cover"));
|
||||
messages.push("cover 요청");
|
||||
}
|
||||
if (track.wavUrl) {
|
||||
downloadUrl(track.wavUrl, `${baseName}.wav`, fail("wav"));
|
||||
const preparedWavUrl = await requestSunoWavUrl(track);
|
||||
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 요청");
|
||||
} else if (track.audioUrl) {
|
||||
downloadUrl(track.audioUrl, `${baseName}.${extensionFromUrl(track.audioUrl, "audio")}`, fail("audio"));
|
||||
@@ -855,25 +925,32 @@
|
||||
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 downloadSunoPackage(track, statusTarget);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
}
|
||||
setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`);
|
||||
}
|
||||
|
||||
function exportSunoManifest(tracks, statusTarget) {
|
||||
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);
|
||||
preparedTracks.push(track);
|
||||
}
|
||||
const manifest = {
|
||||
pageUrl: location.href,
|
||||
capturedAt: new Date().toISOString(),
|
||||
count: tracks.length,
|
||||
tracks,
|
||||
count: preparedTracks.length,
|
||||
tracks: preparedTracks,
|
||||
};
|
||||
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() {
|
||||
@@ -952,13 +1029,13 @@
|
||||
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();
|
||||
if (!track.id && !track.audioUrl && !track.imageUrl) {
|
||||
setStatus(body, "다운로드할 곡 정보를 못 찾았어. 곡 상세/플레이어 화면에서 다시 눌러봐.");
|
||||
return;
|
||||
}
|
||||
downloadSunoPackage(track, body);
|
||||
await downloadSunoPackage(track, body);
|
||||
});
|
||||
|
||||
body.querySelector("[data-cgs-scan-library]").addEventListener("click", () => {
|
||||
@@ -981,10 +1058,10 @@
|
||||
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();
|
||||
updateSummary();
|
||||
exportSunoManifest(libraryTracks, body);
|
||||
await exportSunoManifest(libraryTracks, body);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,17 @@ function Get-UrlExtension {
|
||||
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 {
|
||||
param(
|
||||
[string]$Url,
|
||||
@@ -180,8 +191,12 @@ foreach ($track in $tracks) {
|
||||
$model = Get-DeepValue $track @("model_name", "model", "major_model_version")
|
||||
$createdAt = Get-DeepValue $track @("created_at", "createdAt")
|
||||
|
||||
$wavUrl = if ($track.wavUrl) { "$($track.wavUrl)" } elseif ($id) { "https://cdn1.suno.ai/$id.wav" } else { "" }
|
||||
$audioUrl = if ($track.audioUrl) { "$($track.audioUrl)" } 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") })
|
||||
$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") }
|
||||
|
||||
$inputAudio = Join-Path $workRoot "$prefix.input.wav"
|
||||
@@ -193,7 +208,12 @@ foreach ($track in $tracks) {
|
||||
Write-Host ""
|
||||
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) {
|
||||
Write-Host "WAV 후보 실패. audioUrl로 fallback 시도."
|
||||
$audioOk = Invoke-Download $audioUrl $fallbackAudio
|
||||
|
||||
Reference in New Issue
Block a user