diff --git a/README.md b/README.md
index 7550fce..060292d 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,14 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
2. `처리용 manifest 저장`을 눌러 Downloads 폴더에 `suno-library-YYYY-MM-DD.manifest.json`을 저장합니다. 이 단계에서 브라우저가 SUNO의 `convert_wav/`와 `wav_file/` API를 호출해 WAV URL을 준비합니다.
3. `run_process_suno_library.ps1`을 실행합니다.
+더 편한 흐름:
+
+1. 먼저 `run_suno_companion.ps1`을 실행해서 로컬 수신 프로그램을 켜둡니다.
+2. SUNO 라이브러리에서 `라이브러리 곡 감지`를 누릅니다.
+3. `로컬 프로그램으로 처리`를 누릅니다.
+
+이 경우 Tampermonkey가 manifest를 `http://127.0.0.1:17873/process`로 보내고, 로컬 프로그램이 자동으로 다운로드/FLAC 변환/정리를 시작합니다.
+
기본 출력 위치:
```text
@@ -59,3 +67,5 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
```
로컬 처리기는 manifest에 저장된 WAV URL을 다운로드하고, 실패하면 manifest의 audio URL로 fallback합니다. 이후 ffmpeg로 FLAC 변환하면서 title, artist, album, genre/style, lyrics, Suno clip id, cover art를 메타데이터로 넣습니다.
+
+로그는 `companion-logs` 폴더에 저장됩니다.
diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js
index bda6525..9baa6a7 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.3
+// @version 0.5.0
// @description Copy structured ChatGPT song prompt versions into Suno fields.
// @author local
// @match https://chatgpt.com/*
@@ -13,6 +13,8 @@
// @connect cdn*.suno.ai
// @connect *.suno.com
// @connect studio-api-prod.suno.com
+// @connect 127.0.0.1
+// @connect localhost
// @run-at document-start
// @grant GM_addStyle
// @grant GM_getValue
@@ -953,6 +955,37 @@
setStatus(statusTarget, `${preparedTracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`);
}
+ async function postToCompanion(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: preparedTracks.length,
+ tracks: preparedTracks,
+ };
+ const response = await fetch("http://127.0.0.1:17873/process", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(manifest),
+ });
+ const text = await response.text();
+ if (!response.ok) {
+ throw new Error(text || `${response.status} ${response.statusText}`);
+ }
+ const data = tryParseJson(text) || {};
+ setStatus(statusTarget, `로컬 처리 시작: ${data.jobId || "job"} / ${data.log || "companion console 확인"}`);
+ }
+
async function bootSuno() {
const body = createPanel("Suno Prompt Paste");
const slots = await readSlots();
@@ -979,6 +1012,9 @@
+
+
+
@@ -1063,6 +1099,35 @@
updateSummary();
await exportSunoManifest(libraryTracks, body);
});
+
+ body.querySelector("[data-cgs-send-companion]").addEventListener("click", async () => {
+ if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks();
+ updateSummary();
+ try {
+ await postToCompanion(libraryTracks, body);
+ } catch (error) {
+ setStatus(body, `로컬 프로그램 연결 실패: ${error.message || error}`);
+ }
+ });
+ }
+
+ function safeBootSuno() {
+ try {
+ bootSuno();
+ } catch (error) {
+ console.error("[ChatGPT to Suno]", error);
+ createErrorPanel(error);
+ }
+ }
+
+ function safeBootChatGPT() {
+ try {
+ const versions = parseVersions(getConversationText());
+ if (versions.length) bootChatGPT();
+ } catch (error) {
+ console.error("[ChatGPT to Suno]", error);
+ createErrorPanel(error);
+ }
}
function startApp() {
@@ -1071,23 +1136,18 @@
return;
}
- try {
- if (isChatGPT) bootChatGPT();
- if (isSuno) bootSuno();
- } catch (error) {
- console.error("[ChatGPT to Suno]", error);
- createErrorPanel(error);
- }
+ if (isSuno) safeBootSuno();
+ if (isChatGPT) safeBootChatGPT();
setInterval(() => {
- if (!document.querySelector("#cgs-panel")) {
- try {
- if (isChatGPT) bootChatGPT();
- if (isSuno) bootSuno();
- } catch (error) {
- console.error("[ChatGPT to Suno]", error);
- createErrorPanel(error);
- }
+ if (isSuno && !document.querySelector("#cgs-panel")) {
+ safeBootSuno();
+ }
+ if (isChatGPT) {
+ const hasPanel = Boolean(document.querySelector("#cgs-panel"));
+ const hasVersions = parseVersions(getConversationText()).length > 0;
+ if (hasVersions && !hasPanel) safeBootChatGPT();
+ if (!hasVersions && hasPanel) document.querySelector("#cgs-panel")?.remove();
}
}, 2000);
}
diff --git a/run_suno_companion.ps1 b/run_suno_companion.ps1
new file mode 100644
index 0000000..53d21ea
--- /dev/null
+++ b/run_suno_companion.ps1
@@ -0,0 +1,7 @@
+$ErrorActionPreference = "Stop"
+
+& "$PSScriptRoot\suno-companion.ps1" `
+ -Port 17873 `
+ -OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨\SUNO"
+
+Read-Host "Enter를 누르면 닫힘"
diff --git a/suno-companion.ps1 b/suno-companion.ps1
new file mode 100644
index 0000000..726c033
--- /dev/null
+++ b/suno-companion.ps1
@@ -0,0 +1,183 @@
+param(
+ [int]$Port = 17873,
+ [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨\SUNO",
+ [string]$FfmpegPath = ""
+)
+
+$ErrorActionPreference = "Stop"
+
+$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+$processor = Join-Path $scriptRoot "process-suno-library.ps1"
+$inbox = Join-Path $scriptRoot "companion-inbox"
+$logs = Join-Path $scriptRoot "companion-logs"
+New-Item -ItemType Directory -Force -Path $inbox, $logs | Out-Null
+
+function Write-Response {
+ param(
+ [System.Net.Sockets.NetworkStream]$Stream,
+ [int]$StatusCode,
+ [string]$StatusText,
+ [string]$Body,
+ [string]$ContentType = "application/json; charset=utf-8"
+ )
+
+ $bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body)
+ $header = @(
+ "HTTP/1.1 $StatusCode $StatusText",
+ "Content-Type: $ContentType",
+ "Content-Length: $($bodyBytes.Length)",
+ "Access-Control-Allow-Origin: *",
+ "Access-Control-Allow-Methods: GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers: content-type",
+ "Connection: close",
+ "",
+ ""
+ ) -join "`r`n"
+ $headerBytes = [Text.Encoding]::ASCII.GetBytes($header)
+ $Stream.Write($headerBytes, 0, $headerBytes.Length)
+ $Stream.Write($bodyBytes, 0, $bodyBytes.Length)
+}
+
+function Read-HttpRequest {
+ param([System.Net.Sockets.NetworkStream]$Stream)
+
+ $buffer = New-Object byte[] 65536
+ $received = New-Object System.Collections.Generic.List[byte]
+ $headerEnd = -1
+
+ while ($headerEnd -lt 0) {
+ $read = $Stream.Read($buffer, 0, $buffer.Length)
+ if ($read -le 0) { break }
+ for ($i = 0; $i -lt $read; $i++) { $received.Add($buffer[$i]) }
+ $text = [Text.Encoding]::ASCII.GetString($received.ToArray())
+ $headerEnd = $text.IndexOf("`r`n`r`n", [StringComparison]::Ordinal)
+ if ($received.Count -gt 1048576) { throw "HTTP header too large" }
+ }
+
+ if ($headerEnd -lt 0) { throw "Invalid HTTP request" }
+
+ $allBytes = $received.ToArray()
+ $headerText = [Text.Encoding]::ASCII.GetString($allBytes, 0, $headerEnd)
+ $lines = $headerText -split "`r`n"
+ $requestLine = $lines[0] -split " "
+ $headers = @{}
+ foreach ($line in $lines | Select-Object -Skip 1) {
+ $idx = $line.IndexOf(":")
+ if ($idx -gt 0) {
+ $headers[$line.Substring(0, $idx).Trim().ToLowerInvariant()] = $line.Substring($idx + 1).Trim()
+ }
+ }
+
+ $contentLength = 0
+ if ($headers.ContainsKey("content-length")) {
+ $contentLength = [int]$headers["content-length"]
+ }
+
+ $bodyStart = $headerEnd + 4
+ $bodyBytes = New-Object System.Collections.Generic.List[byte]
+ for ($i = $bodyStart; $i -lt $allBytes.Length; $i++) { $bodyBytes.Add($allBytes[$i]) }
+
+ while ($bodyBytes.Count -lt $contentLength) {
+ $read = $Stream.Read($buffer, 0, [Math]::Min($buffer.Length, $contentLength - $bodyBytes.Count))
+ if ($read -le 0) { break }
+ for ($i = 0; $i -lt $read; $i++) { $bodyBytes.Add($buffer[$i]) }
+ }
+
+ return [pscustomobject]@{
+ Method = $requestLine[0]
+ Path = $requestLine[1]
+ Headers = $headers
+ Body = [Text.Encoding]::UTF8.GetString($bodyBytes.ToArray())
+ }
+}
+
+function Start-SunoProcess {
+ param([string]$ManifestPath)
+
+ $jobId = Get-Date -Format "yyyyMMdd-HHmmss"
+ $stdout = Join-Path $logs "$jobId.out.log"
+ $stderr = Join-Path $logs "$jobId.err.log"
+ $args = @(
+ "-NoProfile",
+ "-ExecutionPolicy", "Bypass",
+ "-File", $processor,
+ "-ManifestPath", $ManifestPath,
+ "-OutputRoot", $OutputRoot
+ )
+ if ($FfmpegPath) {
+ $args += @("-FfmpegPath", $FfmpegPath)
+ }
+
+ $process = Start-Process -FilePath "powershell.exe" -ArgumentList $args -PassThru -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+ return [pscustomobject]@{
+ jobId = $jobId
+ pid = $process.Id
+ log = $stdout
+ errorLog = $stderr
+ }
+}
+
+if (-not (Test-Path -LiteralPath $processor)) {
+ throw "processor not found: $processor"
+}
+
+$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), $Port)
+$listener.Start()
+
+Write-Host "SUNO companion listening on http://127.0.0.1:$Port"
+Write-Host "OutputRoot: $OutputRoot"
+Write-Host "Logs: $logs"
+Write-Host "Press Ctrl+C to stop."
+
+try {
+ while ($true) {
+ $client = $listener.AcceptTcpClient()
+ try {
+ $stream = $client.GetStream()
+ $request = Read-HttpRequest $stream
+
+ if ($request.Method -eq "OPTIONS") {
+ Write-Response $stream 204 "No Content" ""
+ continue
+ }
+
+ if ($request.Method -eq "GET" -and $request.Path -eq "/health") {
+ Write-Response $stream 200 "OK" (@{ ok = $true; outputRoot = $OutputRoot } | ConvertTo-Json -Compress)
+ continue
+ }
+
+ if ($request.Method -eq "POST" -and $request.Path -eq "/process") {
+ $manifest = $request.Body | ConvertFrom-Json
+ if (-not $manifest.tracks -or @($manifest.tracks).Count -eq 0) {
+ Write-Response $stream 400 "Bad Request" (@{ ok = $false; error = "tracks is empty" } | ConvertTo-Json -Compress)
+ continue
+ }
+
+ $manifestPath = Join-Path $inbox ("suno-library-{0}.manifest.json" -f (Get-Date -Format "yyyyMMdd-HHmmss"))
+ $request.Body | Set-Content -LiteralPath $manifestPath -Encoding UTF8
+ $job = Start-SunoProcess $manifestPath
+
+ Write-Host "Started job $($job.jobId) for $(@($manifest.tracks).Count) tracks"
+ Write-Response $stream 200 "OK" (@{
+ ok = $true
+ jobId = $job.jobId
+ pid = $job.pid
+ log = $job.log
+ errorLog = $job.errorLog
+ } | ConvertTo-Json -Compress)
+ continue
+ }
+
+ Write-Response $stream 404 "Not Found" (@{ ok = $false; error = "not found" } | ConvertTo-Json -Compress)
+ } catch {
+ try {
+ Write-Response $stream 500 "Internal Server Error" (@{ ok = $false; error = "$($_.Exception.Message)" } | ConvertTo-Json -Compress)
+ } catch {}
+ Write-Warning $_.Exception.Message
+ } finally {
+ $client.Close()
+ }
+ }
+} finally {
+ $listener.Stop()
+}