From cc9399c37fba40c92da3dec002c1c2ce72dbfa13 Mon Sep 17 00:00:00 2001 From: "DESKTOP-KSVGT20\\shkim" Date: Sat, 2 May 2026 00:55:16 +0900 Subject: [PATCH] Batch upload Suno MP3s with metadata --- chatgpt-suno-tampermonkey.user.js | 116 ++++++++++++++++++++++++++---- process-suno-library.ps1 | 48 +++++++++---- suno-companion.ps1 | 15 ++-- 3 files changed, 148 insertions(+), 31 deletions(-) diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js index 40428cd..3d7ba4c 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.8.0 +// @version 0.8.1 // @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.8.0"; + const SCRIPT_VERSION = "0.8.1"; const STORAGE_KEY = "chatgptSunoPromptSlots"; const SLOT_COUNT = 2; const isChatGPT = location.hostname.includes("chatgpt.com") || location.hostname.includes("chat.openai.com"); @@ -1095,6 +1095,52 @@ throw new Error("브라우저도 WAV 다운로드를 못 받았어"); } + function getMp3Candidates(track) { + const candidates = []; + if (track.audioUrl && !/\.wav(?:\?|$)/i.test(track.audioUrl)) candidates.push(track.audioUrl); + const metadata = track.metadata || {}; + for (const value of [ + metadata.audio_url, + metadata.audioUrl, + metadata.stream_audio_url, + metadata.streamAudioUrl, + metadata.play_url, + metadata.playUrl, + metadata.play_path, + metadata.playPath, + metadata.mp3_url, + metadata.mp3Url, + ]) { + const normalized = normalizeSunoMediaUrl(value); + if (normalized && !/\.wav(?:\?|$)/i.test(normalized)) candidates.push(normalized); + } + if (track.id) { + candidates.push(`https://cdn1.suno.ai/${encodeURIComponent(track.id)}.mp3`); + candidates.push(`https://cdn2.suno.ai/${encodeURIComponent(track.id)}.mp3`); + } + return unique(candidates); + } + + async function downloadSunoMp3Buffer(track, statusTarget) { + const candidates = getMp3Candidates(track); + if (!candidates.length) throw new Error("MP3 후보 URL 없음"); + for (const url of candidates) { + try { + setStatus(statusTarget, `브라우저에서 MP3 받는 중: ${track.title || track.id}`); + const response = await callTampermonkeyBytes(url, { method: "GET" }); + const size = response.buffer?.byteLength || 0; + if (response.ok && size > 128 * 1024) { + track.audioUrl = url; + return response.buffer; + } + console.warn("[ChatGPT to Suno] MP3 bytes not ready", url, response.status, size); + } catch (error) { + console.warn("[ChatGPT to Suno] MP3 download failed", url, error); + } + } + throw new Error("브라우저도 MP3 다운로드를 못 받았어"); + } + function downloadTextFile(text, name) { const blob = new Blob([text], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -1247,14 +1293,17 @@ setStatus(statusTarget, `큐 전송 완료: 새 곡 ${enqueued}개, 보류 ${wavPending}개. 로컬 프로그램이 WAV 준비를 기다리며 처리 중.`); } - async function uploadTrackToCompanion(track, statusTarget) { - const buffer = await downloadSunoWavBuffer(track, statusTarget); - setStatus(statusTarget, `로컬 프로그램으로 WAV 업로드 중: ${track.title || track.id}`); + async function uploadTrackToCompanion(track, statusTarget, format = "mp3") { + const buffer = format === "wav" + ? await downloadSunoWavBuffer(track, statusTarget) + : await downloadSunoMp3Buffer(track, statusTarget); + setStatus(statusTarget, `로컬 프로그램으로 ${format.toUpperCase()} 업로드 중: ${track.title || track.id}`); const payload = { pageUrl: location.href, capturedAt: new Date().toISOString(), track, - wavBase64: arrayBufferToBase64(buffer), + audioExt: format, + audioBase64: arrayBufferToBase64(buffer), }; const response = await fetch("http://127.0.0.1:17873/upload-track", { method: "POST", @@ -1268,6 +1317,50 @@ return tryParseJson(text) || {}; } + async function uploadTracksToCompanion(tracks, statusTarget, format = "mp3") { + if (!tracks.length) { + setStatus(statusTarget, "보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘."); + return; + } + const syncManifest = { + pageUrl: location.href, + capturedAt: new Date().toISOString(), + count: tracks.length, + tracks, + }; + setStatus(statusTarget, `${tracks.length}곡 로컬 폴더와 비교 중...`); + const syncResponse = await fetch("http://127.0.0.1:17873/sync-index", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(syncManifest), + }); + const syncText = await syncResponse.text(); + if (!syncResponse.ok) throw new Error(syncText || `${syncResponse.status} ${syncResponse.statusText}`); + const syncData = tryParseJson(syncText) || {}; + const missingTracks = Array.isArray(syncData.tracks) ? syncData.tracks : []; + if (!missingTracks.length) { + setStatus(statusTarget, `동기화 완료 상태: ${tracks.length}곡 모두 로컬에 있음`); + return; + } + + let uploaded = 0; + let failed = 0; + for (let index = 0; index < missingTracks.length; index += 1) { + const track = { ...missingTracks[index] }; + try { + setStatus(statusTarget, `${index + 1}/${missingTracks.length} ${format.toUpperCase()} 처리 중: ${track.title || track.id}`); + await uploadTrackToCompanion(track, statusTarget, format); + uploaded += 1; + } catch (error) { + failed += 1; + console.warn("[ChatGPT to Suno] upload failed", track, error); + setStatus(statusTarget, `${track.title || track.id} 실패: ${error.message || error}`); + } + await new Promise((resolve) => setTimeout(resolve, 400)); + } + setStatus(statusTarget, `${format.toUpperCase()} 일괄 업로드 완료: ${uploaded}곡 성공, ${failed}곡 실패. 로컬에서 메타데이터 후처리 중.`); + } + function trackOptionLabel(track, index) { const title = track?.title || track?.id || "제목 없음"; const shortId = track?.id ? String(track.id).slice(0, 8) : ""; @@ -1325,7 +1418,7 @@
- +
@@ -1334,7 +1427,7 @@
- +
`; @@ -1416,7 +1509,7 @@ return; } try { - const data = await uploadTrackToCompanion(track, body); + const data = await uploadTrackToCompanion(track, body, "mp3"); setStatus(body, `선택 1곡 업로드 완료: ${track.title || track.id} / 대기 ${data.queued ?? "?"}개 / 처리 ${data.active || "대기"}`); } catch (error) { setStatus(body, `선택 1곡 처리 실패: ${error.message || error}`); @@ -1430,10 +1523,9 @@ } updateSummary(); try { - await postToCompanion(libraryTracks, body); + await uploadTracksToCompanion(libraryTracks, body, "mp3"); } catch (error) { - setStatus(body, `로컬 프로그램 연결 실패. manifest로 저장할게: ${error.message || error}`); - await exportSunoManifest(libraryTracks, body); + setStatus(body, `MP3 일괄 처리 실패: ${error.message || error}`); } }); diff --git a/process-suno-library.ps1 b/process-suno-library.ps1 index 181c610..7e08a99 100644 --- a/process-suno-library.ps1 +++ b/process-suno-library.ps1 @@ -402,7 +402,17 @@ 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") + $localAudioPath = Get-DeepValue $track @("localAudioPath", "local_audio_path", "uploadedAudioPath") + $localAudioExt = Get-DeepValue $track @("localAudioExt", "local_audio_ext", "audioExt") $localWavPath = Get-DeepValue $track @("localWavPath", "local_wav_path", "uploadedWavPath") + if (-not $localAudioPath -and $localWavPath) { + $localAudioPath = $localWavPath + $localAudioExt = "wav" + } + if (-not $localAudioExt -and $localAudioPath) { + $localAudioExt = [IO.Path]::GetExtension($localAudioPath).TrimStart(".").ToLowerInvariant() + } + $outputExt = if ($localAudioExt -eq "mp3") { "mp3" } else { "flac" } $wavUrl = Resolve-SunoMediaUrl $(if ($track.wavUrl) { "$($track.wavUrl)" } else { Get-DeepValue $track @("wav_url", "wavUrl", "download_url", "downloadUrl") }) $wavCandidates = @() @@ -410,26 +420,27 @@ foreach ($track in $tracks) { $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" + $inputExt = if ($localAudioExt) { $localAudioExt } else { "wav" } + $inputAudio = Join-Path $workRoot "$prefix.input.$inputExt" $rawCoverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')" $trackCoverFile = Join-Path $workRoot "$prefix.embed-cover.jpg" $coverFile = Join-Path $trackFolder "cover.jpg" $metadataFile = Join-Path $trackFolder ("metadata.{0}.json" -f $shortId) $lyricsFile = Join-Path $trackFolder ("{0}.lrc" -f $prefix) - $flacFile = Join-Path $trackFolder ("{0}.flac" -f $prefix) + $outputFile = Join-Path $trackFolder ("{0}.{1}" -f $prefix, $outputExt) Write-Host "" Write-Host "[$index/$($tracks.Count)] $title" - if (-not $localWavPath -and -not $wavCandidates.Count) { - Write-Warning "WAV URL이 없어서 건너뜀: $title" + if (-not $localAudioPath -and -not $wavCandidates.Count) { + Write-Warning "오디오 입력이 없어서 건너뜀: $title" continue } $audioOk = $false - if ($localWavPath -and (Test-Path -LiteralPath $localWavPath)) { - Write-Host "업로드된 WAV 사용: $localWavPath" - Copy-Item -LiteralPath $localWavPath -Destination $inputAudio -Force + if ($localAudioPath -and (Test-Path -LiteralPath $localAudioPath)) { + Write-Host "업로드된 오디오 사용: $localAudioPath" + Copy-Item -LiteralPath $localAudioPath -Destination $inputAudio -Force $audioOk = (Test-Path -LiteralPath $inputAudio) -and ((Get-Item -LiteralPath $inputAudio).Length -gt 0) } else { $audioOk = Invoke-DownloadWithRetry $wavCandidates $inputAudio $WavRetrySeconds $WavRetryIntervalSeconds @@ -481,10 +492,19 @@ foreach ($track in $tracks) { $argsList.Add("-metadata:s:v") $argsList.Add("comment=Cover (front)") } - $argsList.Add("-c:a") - $argsList.Add("flac") - $argsList.Add("-compression_level") - $argsList.Add("8") + if ($outputExt -eq "mp3") { + $argsList.Add("-c:a") + $argsList.Add("copy") + $argsList.Add("-id3v2_version") + $argsList.Add("3") + $argsList.Add("-write_id3v1") + $argsList.Add("1") + } else { + $argsList.Add("-c:a") + $argsList.Add("flac") + $argsList.Add("-compression_level") + $argsList.Add("8") + } Add-MetadataArg $argsList "TITLE" $title Add-MetadataArg $argsList "ARTIST" $ArtistName Add-MetadataArg $argsList "ALBUM" $albumTitle @@ -501,15 +521,15 @@ foreach ($track in $tracks) { Add-MetadataArg $argsList "SUNO_MODEL" $model Add-MetadataArg $argsList "SUNO_CLIP_ID" $id Add-MetadataArg $argsList "TMED" "Digital Media" - $argsList.Add($flacFile) + $argsList.Add($outputFile) & $ffmpeg @argsList if ($LASTEXITCODE -ne 0) { - Write-Warning "FLAC 변환 실패: $title" + Write-Warning "오디오 후처리 실패: $title" continue } - Write-Host "완료: $flacFile" + Write-Host "완료: $outputFile" Write-AlbumNfo $trackFolder $albumTitle $ArtistName $style $ffprobe } diff --git a/suno-companion.ps1 b/suno-companion.ps1 index 1dfcc9e..bee07eb 100644 --- a/suno-companion.ps1 +++ b/suno-companion.ps1 @@ -252,16 +252,21 @@ function Save-UploadedTrack { param($Payload) if (-not $Payload.track) { throw "track is required" } - if (-not $Payload.wavBase64) { throw "wavBase64 is required" } + $audioBase64 = if ($Payload.audioBase64) { "$($Payload.audioBase64)" } else { "$($Payload.wavBase64)" } + if (-not $audioBase64) { throw "audioBase64 is required" } $track = $Payload.track $id = "$($track.id)".Trim() if (-not $id) { $id = Get-Date -Format "yyyyMMdd-HHmmssfff" } + $ext = "$($Payload.audioExt)".Trim().TrimStart(".").ToLowerInvariant() + if (-not $ext) { $ext = "wav" } + if ($ext -notmatch '^[a-z0-9]{2,5}$') { $ext = "bin" } - $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 + $audioPath = Join-Path $inbox ("suno-upload-{0}.{1}" -f $id, $ext) + $bytes = [Convert]::FromBase64String($audioBase64) + [IO.File]::WriteAllBytes($audioPath, $bytes) + $track | Add-Member -NotePropertyName "localAudioPath" -NotePropertyValue $audioPath -Force + $track | Add-Member -NotePropertyName "localAudioExt" -NotePropertyValue $ext -Force return Add-TracksToQueue @($track) "$($Payload.pageUrl)" }