From d19d93db3a90cf45ac18a2b204c7df4f52976331 Mon Sep 17 00:00:00 2001 From: "DESKTOP-KSVGT20\\shkim" Date: Fri, 1 May 2026 17:49:41 +0900 Subject: [PATCH] Add local Suno FLAC processing workflow --- README.md | 18 +- chatgpt-suno-tampermonkey.user.js | 38 ++++- process-suno-library.ps1 | 262 ++++++++++++++++++++++++++++++ run_process_suno_library.ps1 | 18 ++ 4 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 process-suno-library.ps1 create mode 100644 run_process_suno_library.ps1 diff --git a/README.md b/README.md index 8731b41..2d99f90 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ SUNO 화면 구조가 바뀌어 자동 입력이 실패하면, 패널의 `Title` SUNO 곡 상세/플레이어 화면에서 `WAV + 커버 + 메타 다운로드`를 누르면 현재 페이지에서 감지된 곡 정보를 기준으로 아래 파일을 다운로드합니다. -SUNO 라이브러리 목록 화면에서는 `라이브러리 곡 감지`를 누른 뒤 `감지된 라이브러리 일괄 다운로드`를 누릅니다. 현재 브라우저에 로드된 곡 목록을 기준으로 처리하므로, 무한 스크롤 목록에서는 먼저 아래쪽까지 스크롤해서 곡을 로드해야 합니다. +SUNO 라이브러리 목록 화면에서는 `라이브러리 곡 감지`를 누른 뒤 `처리용 manifest 저장`을 누릅니다. 현재 브라우저에 로드된 곡 목록을 기준으로 처리하므로, 무한 스크롤 목록에서는 먼저 아래쪽까지 스크롤해서 곡을 로드해야 합니다. - `{title}.wav`: clip id가 감지되면 `https://cdn1.suno.ai/{clipId}.wav` 후보를 내려받습니다. - `{title}.cover.*`: 페이지에서 감지된 앨범아트 URL을 내려받습니다. @@ -43,3 +43,19 @@ SUNO 라이브러리 목록 화면에서는 `라이브러리 곡 감지`를 누 - `suno-library-{date}.manifest.json`: 일괄 다운로드 시 감지된 전체 곡 목록을 저장합니다. WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책에 따라 실패할 수 있습니다. 이 경우 metadata JSON의 `detectedAudioUrls`, `detectedImageUrls`, `detectedIds`를 보고 실제 페이지에서 어떤 URL이 잡혔는지 확인할 수 있습니다. + +## 로컬 FLAC 정리 + +브라우저는 `\\VaultOfData\AKAMedia\음악\처리됨\SUNO` 같은 로컬/NAS 경로에 직접 파일을 정리하거나 WAV를 FLAC으로 인코딩할 수 없습니다. 그래서 Tampermonkey는 manifest를 만들고, 로컬 PowerShell 스크립트가 실제 다운로드/변환/정리를 담당합니다. + +1. SUNO 라이브러리에서 `라이브러리 곡 감지`를 누릅니다. +2. `처리용 manifest 저장`을 눌러 Downloads 폴더에 `suno-library-YYYY-MM-DD.manifest.json`을 저장합니다. +3. `run_process_suno_library.ps1`을 실행합니다. + +기본 출력 위치: + +```text +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\YYYY-MM-DD +``` + +로컬 처리기는 각 곡의 WAV 후보를 다운로드하고, 실패하면 manifest의 audio URL로 fallback합니다. 이후 ffmpeg로 FLAC 변환하면서 title, artist, album, genre/style, lyrics, Suno clip id, cover art를 메타데이터로 넣습니다. diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js index 5c2049b..3de1c25 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.3.1 +// @version 0.4.0 // @description Copy structured ChatGPT song prompt versions into Suno fields. // @author local // @match https://chatgpt.com/* @@ -591,9 +591,17 @@ const imageUrl = firstValue(object, ["image_url", "imageUrl", "image_large_url", "imageLargeUrl", "cover_url", "coverUrl"]); const audioUrl = firstValue(object, ["audio_url", "audioUrl", "play_url", "playUrl", "mp3_url", "mp3Url"]); const wavUrl = firstValue(object, ["wav_url", "wavUrl"]) || (id ? `https://cdn1.suno.ai/${id}.wav` : ""); + const lyrics = firstValue(object, ["prompt", "lyrics", "lyric", "text"]); + const style = firstValue(object, ["gpt_description_prompt", "style", "tags", "description"]); + const model = firstValue(object, ["model_name", "model", "major_model_version"]); + const createdAt = firstValue(object, ["created_at", "createdAt"]); return { id, title, + lyrics, + style, + model, + createdAt, audioUrl, wavUrl, imageUrl, @@ -605,7 +613,7 @@ function mergeTrack(target, source) { if (!target) return source; - for (const key of ["title", "audioUrl", "wavUrl", "imageUrl", "pageUrl"]) { + for (const key of ["title", "lyrics", "style", "model", "createdAt", "audioUrl", "wavUrl", "imageUrl", "pageUrl"]) { if (!target[key] && source[key]) target[key] = source[key]; } target.metadata = target.metadata && Object.keys(target.metadata).length ? target.metadata : source.metadata; @@ -780,6 +788,21 @@ setStatus(statusTarget, `${tracks.length}곡 다운로드 요청 완료. 실패한 파일은 브라우저 다운로드 목록/metadata에서 확인해줘.`); } + function exportSunoManifest(tracks, statusTarget) { + if (!tracks.length) { + setStatus(statusTarget, "내보낼 곡을 못 찾았어. 라이브러리 곡 감지를 먼저 해줘."); + return; + } + const manifest = { + pageUrl: location.href, + capturedAt: new Date().toISOString(), + count: tracks.length, + tracks, + }; + downloadTextFile(JSON.stringify(manifest, null, 2), `suno-library-${new Date().toISOString().slice(0, 10)}.manifest.json`); + setStatus(statusTarget, `${tracks.length}곡 manifest 저장. 이제 run_process_suno_library.ps1 실행하면 정리/FLAC 변환돼.`); + } + async function bootSuno() { const body = createPanel("Suno Prompt Paste"); const slots = await readSlots(); @@ -804,7 +827,10 @@
- + +
+
+
`; @@ -880,6 +906,12 @@ updateSummary(); await downloadSunoBatch(libraryTracks, body); }); + + body.querySelector("[data-cgs-export-library]").addEventListener("click", () => { + if (!libraryTracks.length) libraryTracks = scanSunoLibraryTracks(); + updateSummary(); + exportSunoManifest(libraryTracks, body); + }); } function startApp() { diff --git a/process-suno-library.ps1 b/process-suno-library.ps1 new file mode 100644 index 0000000..9c5bb6a --- /dev/null +++ b/process-suno-library.ps1 @@ -0,0 +1,262 @@ +param( + [string]$ManifestPath = "", + [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨\SUNO", + [string]$FfmpegPath = "", + [switch]$KeepWorkFiles +) + +$ErrorActionPreference = "Stop" + +function Resolve-Ffmpeg { + param([string]$PreferredPath) + + if ($PreferredPath -and (Test-Path -LiteralPath $PreferredPath)) { + return (Resolve-Path -LiteralPath $PreferredPath).Path + } + + $repoCandidate = "E:\forfun\ffmpeg\bin\ffmpeg.exe" + if (Test-Path -LiteralPath $repoCandidate) { + return $repoCandidate + } + + $cmd = Get-Command ffmpeg -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + throw "ffmpeg.exe를 찾지 못했어. -FfmpegPath 로 경로를 지정해줘." +} + +function Resolve-Manifest { + param([string]$Path) + + if ($Path -and (Test-Path -LiteralPath $Path)) { + return (Resolve-Path -LiteralPath $Path).Path + } + + $candidates = @() + $downloads = Join-Path $env:USERPROFILE "Downloads" + if (Test-Path -LiteralPath $downloads) { + $candidates += Get-ChildItem -LiteralPath $downloads -Filter "suno-library-*.manifest.json" -File -ErrorAction SilentlyContinue + } + $candidates += Get-ChildItem -LiteralPath (Get-Location) -Filter "suno-library-*.manifest.json" -File -ErrorAction SilentlyContinue + + $latest = $candidates | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($latest) { + return $latest.FullName + } + + throw "manifest 파일을 찾지 못했어. -ManifestPath 로 suno-library-*.manifest.json 경로를 넘겨줘." +} + +function ConvertTo-SafeName { + param( + [string]$Value, + [string]$Fallback = "suno-track" + ) + + $name = if ($Value) { $Value } else { $Fallback } + $name = $name -replace '[\\/:*?"<>|]+', ' ' + $name = $name -replace '\s+', ' ' + $name = $name.Trim() + if (-not $name) { $name = $Fallback } + if ($name.Length -gt 120) { $name = $name.Substring(0, 120).Trim() } + return $name +} + +function Get-ObjectValues { + param($Value) + + if ($null -eq $Value) { return } + + if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string]) -and -not ($Value -is [pscustomobject])) { + foreach ($item in $Value) { Get-ObjectValues $item } + return + } + + if ($Value -is [pscustomobject]) { + $Value + foreach ($prop in $Value.PSObject.Properties) { + Get-ObjectValues $prop.Value + } + } +} + +function Get-DeepValue { + param( + $Object, + [string[]]$Keys + ) + + foreach ($node in Get-ObjectValues $Object) { + foreach ($key in $Keys) { + $prop = $node.PSObject.Properties[$key] + if ($prop -and $null -ne $prop.Value -and "$($prop.Value)".Trim()) { + return "$($prop.Value)".Trim() + } + } + } + + return "" +} + +function Get-UrlExtension { + param( + [string]$Url, + [string]$Fallback + ) + + $clean = ($Url -split '[?#]')[0] + $ext = [IO.Path]::GetExtension($clean).TrimStart(".") + if ($ext) { return $ext.ToLowerInvariant() } + return $Fallback +} + +function Invoke-Download { + param( + [string]$Url, + [string]$OutFile + ) + + if (-not $Url) { return $false } + + $headers = @{ + "User-Agent" = "Mozilla/5.0" + "Referer" = "https://suno.com/" + } + + try { + Invoke-WebRequest -Uri $Url -OutFile $OutFile -Headers $headers -UseBasicParsing + return (Test-Path -LiteralPath $OutFile) -and ((Get-Item -LiteralPath $OutFile).Length -gt 0) + } catch { + Write-Warning "다운로드 실패: $Url :: $($_.Exception.Message)" + return $false + } +} + +function Add-MetadataArg { + param( + [System.Collections.Generic.List[string]]$Args, + [string]$Key, + [string]$Value + ) + + if ($Value) { + $Args.Add("-metadata") + $Args.Add("$Key=$Value") + } +} + +$ffmpeg = Resolve-Ffmpeg $FfmpegPath +$manifestFile = Resolve-Manifest $ManifestPath +$manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json +$tracks = @($manifest.tracks) + +if (-not $tracks.Count) { + throw "manifest에 tracks 배열이 없어." +} + +$dateFolder = Get-Date -Format "yyyy-MM-dd" +$targetRoot = Join-Path $OutputRoot $dateFolder +$workRoot = Join-Path $targetRoot "_work" +New-Item -ItemType Directory -Force -Path $targetRoot, $workRoot | Out-Null + +Write-Host "Manifest: $manifestFile" +Write-Host "Output: $targetRoot" +Write-Host "ffmpeg: $ffmpeg" +Write-Host "Tracks: $($tracks.Count)" + +$index = 0 +foreach ($track in $tracks) { + $index++ + + $id = if ($track.id) { "$($track.id)" } else { "" } + $title = if ($track.title) { "$($track.title)" } else { $id } + $safeTitle = ConvertTo-SafeName $title $id + $prefix = "{0:D3} - {1}" -f $index, $safeTitle + + $lyrics = Get-DeepValue $track @("prompt", "lyrics", "lyric", "text") + $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") + + $wavUrl = if ($track.wavUrl) { "$($track.wavUrl)" } elseif ($id) { "https://cdn1.suno.ai/$id.wav" } else { "" } + $audioUrl = if ($track.audioUrl) { "$($track.audioUrl)" } else { "" } + $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" + $fallbackAudio = Join-Path $workRoot "$prefix.input.$(Get-UrlExtension $audioUrl 'audio')" + $coverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')" + $metadataFile = Join-Path $targetRoot "$prefix.metadata.json" + $flacFile = Join-Path $targetRoot "$prefix.flac" + + Write-Host "" + Write-Host "[$index/$($tracks.Count)] $title" + + $audioOk = Invoke-Download $wavUrl $inputAudio + if (-not $audioOk -and $audioUrl) { + Write-Host "WAV 후보 실패. audioUrl로 fallback 시도." + $audioOk = Invoke-Download $audioUrl $fallbackAudio + if ($audioOk) { $inputAudio = $fallbackAudio } + } + + if (-not $audioOk) { + Write-Warning "오디오를 받지 못해서 건너뜀: $title" + continue + } + + $coverOk = $false + if ($imageUrl) { + $coverOk = Invoke-Download $imageUrl $coverFile + } + + $track | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $metadataFile -Encoding UTF8 + + $argsList = [System.Collections.Generic.List[string]]::new() + $argsList.Add("-hide_banner") + $argsList.Add("-y") + $argsList.Add("-i") + $argsList.Add($inputAudio) + if ($coverOk) { + $argsList.Add("-i") + $argsList.Add($coverFile) + $argsList.Add("-map") + $argsList.Add("0:a") + $argsList.Add("-map") + $argsList.Add("1:v") + $argsList.Add("-disposition:v") + $argsList.Add("attached_pic") + $argsList.Add("-c:v") + $argsList.Add("copy") + } + $argsList.Add("-c:a") + $argsList.Add("flac") + $argsList.Add("-compression_level") + $argsList.Add("8") + Add-MetadataArg $argsList "title" $title + Add-MetadataArg $argsList "artist" "SUNO" + Add-MetadataArg $argsList "album" "SUNO" + Add-MetadataArg $argsList "genre" $style + Add-MetadataArg $argsList "lyrics" $lyrics + Add-MetadataArg $argsList "description" $style + Add-MetadataArg $argsList "comment" "Suno clip id: $id" + Add-MetadataArg $argsList "date" $createdAt + Add-MetadataArg $argsList "encoder" "Codex SUNO helper" + Add-MetadataArg $argsList "suno_model" $model + $argsList.Add($flacFile) + + & $ffmpeg @argsList + if ($LASTEXITCODE -ne 0) { + Write-Warning "FLAC 변환 실패: $title" + continue + } + + Write-Host "완료: $flacFile" +} + +if (-not $KeepWorkFiles -and (Test-Path -LiteralPath $workRoot)) { + Remove-Item -LiteralPath $workRoot -Recurse -Force +} + +Write-Host "" +Write-Host "처리 완료: $targetRoot" diff --git a/run_process_suno_library.ps1 b/run_process_suno_library.ps1 new file mode 100644 index 0000000..8c1e034 --- /dev/null +++ b/run_process_suno_library.ps1 @@ -0,0 +1,18 @@ +$ErrorActionPreference = "Stop" + +$manifest = Join-Path $env:USERPROFILE "Downloads\suno-library-*.manifest.json" +$latest = Get-ChildItem -Path $manifest -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + +if (-not $latest) { + Write-Host "Downloads 폴더에서 suno-library-*.manifest.json 을 찾지 못했어." + Write-Host "Tampermonkey 패널에서 라이브러리 일괄 다운로드를 먼저 눌러줘." + Read-Host "Enter를 누르면 닫힘" + exit 1 +} + +& "$PSScriptRoot\process-suno-library.ps1" -ManifestPath $latest.FullName -OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨\SUNO" + +Write-Host "" +Read-Host "Enter를 누르면 닫힘"