diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js index 34817d1..40428cd 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.7.9 +// @version 0.8.0 // @description Copy structured ChatGPT song prompt versions into Suno fields. // @author local // @match https://chatgpt.com/* @@ -28,7 +28,7 @@ (function () { "use strict"; - const SCRIPT_VERSION = "0.7.9"; + const SCRIPT_VERSION = "0.8.0"; const STORAGE_KEY = "chatgptSunoPromptSlots"; const SLOT_COUNT = 2; const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com"); @@ -890,6 +890,41 @@ }); } + function callTampermonkeyBytes(url, options = {}) { + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: options.method || "GET", + url, + headers: options.headers || {}, + anonymous: false, + timeout: 120000, + responseType: "arraybuffer", + onload: (response) => { + resolve({ + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText || "", + finalUrl: response.finalUrl || url, + buffer: response.response, + }); + }, + onerror: (error) => reject(new Error(error?.error || "GM_xmlhttpRequest bytes failed")), + ontimeout: () => reject(new Error("GM_xmlhttpRequest bytes timeout")), + }); + }); + } + + function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ""; + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize); + binary += String.fromCharCode(...chunk); + } + return btoa(binary); + } + function extractWavUrl(value) { if (!value) return ""; if (typeof value === "string") { @@ -1019,6 +1054,47 @@ return extractWavUrl(track.wavUrl) || cdnWavUrl; } + async function downloadSunoWavBuffer(track, statusTarget) { + if (!track.id) throw new Error("clip id 없음"); + const base = `https://studio-api-prod.suno.com/api/gen/${encodeURIComponent(track.id)}`; + const candidates = []; + + setStatus(statusTarget, `브라우저에서 WAV 변환 요청 중: ${track.title || track.id}`); + try { + await callSunoJson(`${base}/convert_wav/`, { method: "POST" }); + } catch (error) { + console.warn("[ChatGPT to Suno] convert_wav failed before browser download", error); + } + + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const data = await callSunoJson(`${base}/wav_file/`, { method: "GET" }); + const direct = extractWavUrl(data); + if (direct) candidates.push(direct); + } catch (error) { + console.warn("[ChatGPT to Suno] wav_file probe failed before browser download", error); + } + candidates.push(`https://studio-api-prod.suno.com/api/billing/clips/${encodeURIComponent(track.id)}/download/`); + candidates.push(`https://cdn1.suno.ai/${encodeURIComponent(track.id)}.wav`); + + for (const url of unique(candidates)) { + try { + setStatus(statusTarget, `브라우저에서 WAV 받는 중: ${track.title || track.id}`); + const response = await callTampermonkeyBytes(url, { method: "GET" }); + const size = response.buffer?.byteLength || 0; + if (response.ok && size > 1024 * 1024) { + return response.buffer; + } + console.warn("[ChatGPT to Suno] WAV bytes not ready", url, response.status, size); + } catch (error) { + console.warn("[ChatGPT to Suno] WAV bytes download failed", url, error); + } + } + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + throw new Error("브라우저도 WAV 다운로드를 못 받았어"); + } + function downloadTextFile(text, name) { const blob = new Blob([text], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -1171,6 +1247,27 @@ setStatus(statusTarget, `큐 전송 완료: 새 곡 ${enqueued}개, 보류 ${wavPending}개. 로컬 프로그램이 WAV 준비를 기다리며 처리 중.`); } + async function uploadTrackToCompanion(track, statusTarget) { + const buffer = await downloadSunoWavBuffer(track, statusTarget); + setStatus(statusTarget, `로컬 프로그램으로 WAV 업로드 중: ${track.title || track.id}`); + const payload = { + pageUrl: location.href, + capturedAt: new Date().toISOString(), + track, + wavBase64: arrayBufferToBase64(buffer), + }; + const response = await fetch("http://127.0.0.1:17873/upload-track", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(text || `${response.status} ${response.statusText}`); + } + return tryParseJson(text) || {}; + } + function trackOptionLabel(track, index) { const title = track?.title || track?.id || "제목 없음"; const shortId = track?.id ? String(track.id).slice(0, 8) : ""; @@ -1319,11 +1416,10 @@ return; } try { - setStatus(body, `선택 1곡 처리 준비: ${track.title || track.id}`); - await postToCompanion([track], body); + const data = await uploadTrackToCompanion(track, body); + setStatus(body, `선택 1곡 업로드 완료: ${track.title || track.id} / 대기 ${data.queued ?? "?"}개 / 처리 ${data.active || "대기"}`); } catch (error) { - setStatus(body, `로컬 프로그램 연결 실패. 선택 1곡 manifest로 저장할게: ${error.message || error}`); - await exportSunoManifest([track], body); + setStatus(body, `선택 1곡 처리 실패: ${error.message || error}`); } }); diff --git a/process-suno-library.ps1 b/process-suno-library.ps1 index 4b91fa5..181c610 100644 --- a/process-suno-library.ps1 +++ b/process-suno-library.ps1 @@ -402,6 +402,7 @@ foreach ($track in $tracks) { $style = Get-DeepValue $track @("gpt_description_prompt", "style", "tags", "description") $model = Get-DeepValue $track @("model_name", "model", "major_model_version") $createdAt = Get-DeepValue $track @("created_at", "createdAt") + $localWavPath = Get-DeepValue $track @("localWavPath", "local_wav_path", "uploadedWavPath") $wavUrl = Resolve-SunoMediaUrl $(if ($track.wavUrl) { "$($track.wavUrl)" } else { Get-DeepValue $track @("wav_url", "wavUrl", "download_url", "downloadUrl") }) $wavCandidates = @() @@ -420,12 +421,19 @@ foreach ($track in $tracks) { Write-Host "" Write-Host "[$index/$($tracks.Count)] $title" - if (-not $wavCandidates.Count) { + if (-not $localWavPath -and -not $wavCandidates.Count) { Write-Warning "WAV URL이 없어서 건너뜀: $title" continue } - $audioOk = Invoke-DownloadWithRetry $wavCandidates $inputAudio $WavRetrySeconds $WavRetryIntervalSeconds + $audioOk = $false + if ($localWavPath -and (Test-Path -LiteralPath $localWavPath)) { + Write-Host "업로드된 WAV 사용: $localWavPath" + Copy-Item -LiteralPath $localWavPath -Destination $inputAudio -Force + $audioOk = (Test-Path -LiteralPath $inputAudio) -and ((Get-Item -LiteralPath $inputAudio).Length -gt 0) + } else { + $audioOk = Invoke-DownloadWithRetry $wavCandidates $inputAudio $WavRetrySeconds $WavRetryIntervalSeconds + } if (-not $audioOk) { Write-Warning "WAV를 받지 못해서 건너뜀: $title" continue diff --git a/suno-companion.ps1 b/suno-companion.ps1 index dc4ae32..1dfcc9e 100644 --- a/suno-companion.ps1 +++ b/suno-companion.ps1 @@ -248,6 +248,24 @@ function Add-TracksToQueue { } } +function Save-UploadedTrack { + param($Payload) + + if (-not $Payload.track) { throw "track is required" } + if (-not $Payload.wavBase64) { throw "wavBase64 is required" } + + $track = $Payload.track + $id = "$($track.id)".Trim() + if (-not $id) { $id = Get-Date -Format "yyyyMMdd-HHmmssfff" } + + $wavPath = Join-Path $inbox ("suno-upload-{0}.wav" -f $id) + $bytes = [Convert]::FromBase64String("$($Payload.wavBase64)") + [IO.File]::WriteAllBytes($wavPath, $bytes) + $track | Add-Member -NotePropertyName "localWavPath" -NotePropertyValue $wavPath -Force + + return Add-TracksToQueue @($track) "$($Payload.pageUrl)" +} + function Get-ExistingSunoIds { param([string]$Root) @@ -394,6 +412,20 @@ try { continue } + if ($request.Method -eq "POST" -and $request.Path -eq "/upload-track") { + $payload = $request.Body | ConvertFrom-Json + $result = Save-UploadedTrack $payload + Write-Host "Upload queue update: added=$($result.added), queued=$($result.queued), active=$($result.active)" + Write-Response $stream 200 "OK" (@{ + ok = $true + added = $result.added + skipped = $result.skipped + queued = $result.queued + active = $result.active + } | ConvertTo-Json -Compress) + continue + } + Write-Response $stream 404 "Not Found" (@{ ok = $false; error = "not found" } | ConvertTo-Json -Compress) } catch { try {