From cec4ae3d0c59e9077a42ff3f52f30befe2daad3a Mon Sep 17 00:00:00 2001 From: "DESKTOP-KSVGT20\\shkim" Date: Fri, 1 May 2026 18:03:52 +0900 Subject: [PATCH] Prepare Suno WAV URLs through studio API --- README.md | 4 +- chatgpt-suno-tampermonkey.user.js | 113 +++++++++++++++++++++++++----- process-suno-library.ps1 | 26 ++++++- 3 files changed, 120 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2d99f90..7550fce 100644 --- a/README.md +++ b/README.md @@ -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를 메타데이터로 넣습니다. diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js index 34db6af..bda6525 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.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); }); } diff --git a/process-suno-library.ps1 b/process-suno-library.ps1 index 9c5bb6a..97bc3b1 100644 --- a/process-suno-library.ps1 +++ b/process-suno-library.ps1 @@ -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