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() +}