Upload browser-downloaded Suno WAVs to companion
This commit is contained in:
@@ -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.7.9
|
// @version 0.8.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/*
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const SCRIPT_VERSION = "0.7.9";
|
const SCRIPT_VERSION = "0.8.0";
|
||||||
const STORAGE_KEY = "chatgptSunoPromptSlots";
|
const STORAGE_KEY = "chatgptSunoPromptSlots";
|
||||||
const SLOT_COUNT = 2;
|
const SLOT_COUNT = 2;
|
||||||
const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com");
|
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) {
|
function extractWavUrl(value) {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -1019,6 +1054,47 @@
|
|||||||
return extractWavUrl(track.wavUrl) || cdnWavUrl;
|
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) {
|
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);
|
||||||
@@ -1171,6 +1247,27 @@
|
|||||||
setStatus(statusTarget, `큐 전송 완료: 새 곡 ${enqueued}개, 보류 ${wavPending}개. 로컬 프로그램이 WAV 준비를 기다리며 처리 중.`);
|
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) {
|
function trackOptionLabel(track, index) {
|
||||||
const title = track?.title || track?.id || "제목 없음";
|
const title = track?.title || track?.id || "제목 없음";
|
||||||
const shortId = track?.id ? String(track.id).slice(0, 8) : "";
|
const shortId = track?.id ? String(track.id).slice(0, 8) : "";
|
||||||
@@ -1319,11 +1416,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setStatus(body, `선택 1곡 처리 준비: ${track.title || track.id}`);
|
const data = await uploadTrackToCompanion(track, body);
|
||||||
await postToCompanion([track], body);
|
setStatus(body, `선택 1곡 업로드 완료: ${track.title || track.id} / 대기 ${data.queued ?? "?"}개 / 처리 ${data.active || "대기"}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(body, `로컬 프로그램 연결 실패. 선택 1곡 manifest로 저장할게: ${error.message || error}`);
|
setStatus(body, `선택 1곡 처리 실패: ${error.message || error}`);
|
||||||
await exportSunoManifest([track], body);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ foreach ($track in $tracks) {
|
|||||||
$style = Get-DeepValue $track @("gpt_description_prompt", "style", "tags", "description")
|
$style = Get-DeepValue $track @("gpt_description_prompt", "style", "tags", "description")
|
||||||
$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")
|
||||||
|
$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") })
|
$wavUrl = Resolve-SunoMediaUrl $(if ($track.wavUrl) { "$($track.wavUrl)" } else { Get-DeepValue $track @("wav_url", "wavUrl", "download_url", "downloadUrl") })
|
||||||
$wavCandidates = @()
|
$wavCandidates = @()
|
||||||
@@ -420,12 +421,19 @@ foreach ($track in $tracks) {
|
|||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "[$index/$($tracks.Count)] $title"
|
Write-Host "[$index/$($tracks.Count)] $title"
|
||||||
|
|
||||||
if (-not $wavCandidates.Count) {
|
if (-not $localWavPath -and -not $wavCandidates.Count) {
|
||||||
Write-Warning "WAV URL이 없어서 건너뜀: $title"
|
Write-Warning "WAV URL이 없어서 건너뜀: $title"
|
||||||
continue
|
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) {
|
if (-not $audioOk) {
|
||||||
Write-Warning "WAV를 받지 못해서 건너뜀: $title"
|
Write-Warning "WAV를 받지 못해서 건너뜀: $title"
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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 {
|
function Get-ExistingSunoIds {
|
||||||
param([string]$Root)
|
param([string]$Root)
|
||||||
|
|
||||||
@@ -394,6 +412,20 @@ try {
|
|||||||
continue
|
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)
|
Write-Response $stream 404 "Not Found" (@{ ok = $false; error = "not found" } | ConvertTo-Json -Compress)
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user