From 1414c38bd12b3959702a5d92ae43721fc0934337 Mon Sep 17 00:00:00 2001 From: "DESKTOP-KSVGT20\\shkim" Date: Fri, 1 May 2026 23:10:57 +0900 Subject: [PATCH] Add clip-id based Suno library sync --- .gitignore | 3 + README.md | 14 +++- chatgpt-suno-tampermonkey.user.js | 37 +++++++-- process-suno-library.ps1 | 124 ++++++++++++++++++++++++++---- run_process_suno_library.ps1 | 5 +- run_suno_companion.ps1 | 3 +- suno-companion.ps1 | 98 +++++++++++++++++++++-- 7 files changed, 254 insertions(+), 30 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6642c0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +companion-inbox/ +companion-logs/ +_work/ diff --git a/README.md b/README.md index 476836f..374f615 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,19 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책 기본 출력 위치: ```text -\\VaultOfData\AKAMedia\음악\처리됨\SUNO\YYYY-MM-DD +\\VaultOfData\AKAMedia\음악\처리됨\SUNO ``` -로컬 처리기는 manifest에 저장된 WAV URL만 다운로드합니다. MP3/audio fallback은 사용하지 않습니다. 이후 ffmpeg로 FLAC 변환하면서 title, artist, album, genre/style, lyrics, Suno clip id, cover art를 메타데이터로 넣습니다. +정리 포맷: + +```text +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목 [clipId앞12자리]\01 곡 제목.flac +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목 [clipId앞12자리]\cover.jpg +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목 [clipId앞12자리]\metadata.json +``` + +동기화 비교 키는 곡 제목이 아니라 SUNO clip id입니다. 같은 제목의 곡이 여러 개 있어도 `metadata.json`의 `id`와 폴더명의 id 조각으로 구분합니다. + +로컬 처리기는 manifest에 저장된 WAV URL만 다운로드합니다. MP3/audio fallback은 사용하지 않습니다. 이후 ffmpeg로 FLAC 변환하면서 기존 라이브러리 방식에 맞춰 title, artist, album, album_artist, track, disc, genre/style, lyrics-XXX, Suno clip id, cover art를 메타데이터로 넣습니다. 로그는 `companion-logs` 폴더에 저장됩니다. diff --git a/chatgpt-suno-tampermonkey.user.js b/chatgpt-suno-tampermonkey.user.js index 05125b3..7d84f55 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.5.2 +// @version 0.6.0 // @description Copy structured ChatGPT song prompt versions into Suno fields. // @author local // @match https://chatgpt.com/* @@ -952,10 +952,33 @@ 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; + } + 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}`); + for (let index = 0; index < missingTracks.length; index += 1) { + const track = { ...missingTracks[index] }; + setStatus(statusTarget, `${index + 1}/${missingTracks.length} 없는 곡 WAV 준비 중: ${track.title || track.id}`); track.wavUrl = await requestSunoWavUrl(track); preparedTracks.push(track); } @@ -975,7 +998,11 @@ throw new Error(text || `${response.status} ${response.statusText}`); } const data = tryParseJson(text) || {}; - setStatus(statusTarget, `로컬 처리 시작: ${data.jobId || "job"} / ${data.log || "companion console 확인"}`); + if (data.skipped) { + setStatus(statusTarget, "동기화 완료 상태: 처리할 새 곡 없음"); + return; + } + setStatus(statusTarget, `로컬 처리 시작: ${data.jobId || "job"} / 새 곡 ${preparedTracks.length}개`); } async function testCompanion(statusTarget) { diff --git a/process-suno-library.ps1 b/process-suno-library.ps1 index e60528c..0e84550 100644 --- a/process-suno-library.ps1 +++ b/process-suno-library.ps1 @@ -1,8 +1,10 @@ param( [string]$ManifestPath = "", - [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨\SUNO", + [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨", + [string]$ArtistName = "SUNO", [string]$FfmpegPath = "", - [switch]$KeepWorkFiles + [switch]$KeepWorkFiles, + [switch]$Force ) $ErrorActionPreference = "Stop" @@ -64,6 +66,70 @@ function ConvertTo-SafeName { return $name } +function Get-ShortId { + param([string]$Id) + + if (-not $Id) { return "unknown-id" } + if ($Id.Length -le 12) { return $Id } + return $Id.Substring(0, 12) +} + +function Get-TrackFolder { + param( + [string]$Root, + [string]$Title, + [string]$Id + ) + + $safeTitle = ConvertTo-SafeName $Title $Id + $shortId = Get-ShortId $Id + return Join-Path $Root ("{0} [{1}]" -f $safeTitle, $shortId) +} + +function Get-AlbumTitle { + param( + [string]$Title, + [string]$Id + ) + + return "{0} [{1}]" -f (ConvertTo-SafeName $Title $Id), (Get-ShortId $Id) +} + +function Resolve-LibraryRoot { + param( + [string]$Root, + [string]$Artist + ) + + $trimmed = $Root.TrimEnd("\", "/") + if ((Split-Path -Leaf $trimmed) -ieq $Artist) { + return $trimmed + } + return Join-Path $trimmed $Artist +} + +function Test-ExistingTrack { + param( + [string]$Root, + [string]$Id + ) + + if (-not $Id -or -not (Test-Path -LiteralPath $Root)) { return $false } + + $metadataHit = Get-ChildItem -LiteralPath $Root -Recurse -Filter "*metadata.json" -File -ErrorAction SilentlyContinue | + Where-Object { + try { + $json = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + "$($json.id)" -eq $Id + } catch { + $false + } + } | + Select-Object -First 1 + + return [bool]$metadataHit +} + function Get-ObjectValues { param($Value) @@ -167,13 +233,13 @@ 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 +$libraryRoot = Resolve-LibraryRoot $OutputRoot $ArtistName +New-Item -ItemType Directory -Force -Path $libraryRoot | Out-Null +$workRoot = Join-Path $libraryRoot "_work" +New-Item -ItemType Directory -Force -Path $workRoot | Out-Null Write-Host "Manifest: $manifestFile" -Write-Host "Output: $targetRoot" +Write-Host "Output: $libraryRoot" Write-Host "ffmpeg: $ffmpeg" Write-Host "Tracks: $($tracks.Count)" @@ -183,8 +249,18 @@ foreach ($track in $tracks) { $id = if ($track.id) { "$($track.id)" } else { "" } $title = if ($track.title) { "$($track.title)" } else { $id } + if (-not $Force -and (Test-ExistingTrack $libraryRoot $id)) { + Write-Host "" + Write-Host "[$index/$($tracks.Count)] 이미 있음, 건너뜀: $title [$id]" + continue + } + + $albumTitle = Get-AlbumTitle $title $id + $trackFolder = Get-TrackFolder $libraryRoot $title $id + New-Item -ItemType Directory -Force -Path $trackFolder | Out-Null $safeTitle = ConvertTo-SafeName $title $id - $prefix = "{0:D3} - {1}" -f $index, $safeTitle + $shortId = Get-ShortId $id + $prefix = "{0} [{1}]" -f $safeTitle, $shortId $lyrics = Get-DeepValue $track @("prompt", "lyrics", "lyric", "text") $style = Get-DeepValue $track @("gpt_description_prompt", "style", "tags", "description") @@ -199,9 +275,10 @@ foreach ($track in $tracks) { $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" - $coverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')" - $metadataFile = Join-Path $targetRoot "$prefix.metadata.json" - $flacFile = Join-Path $targetRoot "$prefix.flac" + $rawCoverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')" + $coverFile = Join-Path $trackFolder "cover.jpg" + $metadataFile = Join-Path $trackFolder "metadata.json" + $flacFile = Join-Path $trackFolder ("01 {0}.flac" -f $safeTitle) Write-Host "" Write-Host "[$index/$($tracks.Count)] $title" @@ -219,7 +296,15 @@ foreach ($track in $tracks) { $coverOk = $false if ($imageUrl) { - $coverOk = Invoke-Download $imageUrl $coverFile + $coverDownloaded = Invoke-Download $imageUrl $rawCoverFile + if ($coverDownloaded) { + & $ffmpeg -hide_banner -y -i $rawCoverFile -frames:v 1 $coverFile + $coverOk = ($LASTEXITCODE -eq 0) -and (Test-Path -LiteralPath $coverFile) + if (-not $coverOk) { + Copy-Item -LiteralPath $rawCoverFile -Destination $coverFile -Force + $coverOk = Test-Path -LiteralPath $coverFile + } + } } $track | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $metadataFile -Encoding UTF8 @@ -240,21 +325,28 @@ foreach ($track in $tracks) { $argsList.Add("attached_pic") $argsList.Add("-c:v") $argsList.Add("copy") + $argsList.Add("-metadata:s:v") + $argsList.Add("comment=Cover (front)") } $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 "artist" $ArtistName + Add-MetadataArg $argsList "album" $albumTitle + Add-MetadataArg $argsList "album_artist" $ArtistName + Add-MetadataArg $argsList "ARTISTS" $ArtistName + Add-MetadataArg $argsList "track" "1/1" + Add-MetadataArg $argsList "disc" "1/1" Add-MetadataArg $argsList "genre" $style - Add-MetadataArg $argsList "lyrics" $lyrics + Add-MetadataArg $argsList "lyrics-XXX" $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 + Add-MetadataArg $argsList "TMED" "Digital Media" $argsList.Add($flacFile) & $ffmpeg @argsList @@ -271,4 +363,4 @@ if (-not $KeepWorkFiles -and (Test-Path -LiteralPath $workRoot)) { } Write-Host "" -Write-Host "처리 완료: $targetRoot" +Write-Host "처리 완료: $libraryRoot" diff --git a/run_process_suno_library.ps1 b/run_process_suno_library.ps1 index 8c1e034..534e243 100644 --- a/run_process_suno_library.ps1 +++ b/run_process_suno_library.ps1 @@ -12,7 +12,10 @@ if (-not $latest) { exit 1 } -& "$PSScriptRoot\process-suno-library.ps1" -ManifestPath $latest.FullName -OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨\SUNO" +& "$PSScriptRoot\process-suno-library.ps1" ` + -ManifestPath $latest.FullName ` + -OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨" ` + -ArtistName "SUNO" Write-Host "" Read-Host "Enter를 누르면 닫힘" diff --git a/run_suno_companion.ps1 b/run_suno_companion.ps1 index 53d21ea..325de8c 100644 --- a/run_suno_companion.ps1 +++ b/run_suno_companion.ps1 @@ -2,6 +2,7 @@ $ErrorActionPreference = "Stop" & "$PSScriptRoot\suno-companion.ps1" ` -Port 17873 ` - -OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨\SUNO" + -OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨" ` + -ArtistName "SUNO" Read-Host "Enter를 누르면 닫힘" diff --git a/suno-companion.ps1 b/suno-companion.ps1 index 0856e50..ea82710 100644 --- a/suno-companion.ps1 +++ b/suno-companion.ps1 @@ -1,6 +1,7 @@ param( [int]$Port = 17873, - [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨\SUNO", + [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨", + [string]$ArtistName = "SUNO", [string]$FfmpegPath = "" ) @@ -12,6 +13,21 @@ $inbox = Join-Path $scriptRoot "companion-inbox" $logs = Join-Path $scriptRoot "companion-logs" New-Item -ItemType Directory -Force -Path $inbox, $logs | Out-Null +function Resolve-LibraryRoot { + param( + [string]$Root, + [string]$Artist + ) + + $trimmed = $Root.TrimEnd("\", "/") + if ((Split-Path -Leaf $trimmed) -ieq $Artist) { + return $trimmed + } + return Join-Path $trimmed $Artist +} + +$libraryRoot = Resolve-LibraryRoot $OutputRoot $ArtistName + function Write-Response { param( [System.Net.Sockets.NetworkStream]$Stream, @@ -103,13 +119,16 @@ function Start-SunoProcess { "-ExecutionPolicy", "Bypass", "-File", $processor, "-ManifestPath", $ManifestPath, - "-OutputRoot", $OutputRoot + "-OutputRoot", $OutputRoot, + "-ArtistName", $ArtistName ) if ($FfmpegPath) { $args += @("-FfmpegPath", $FfmpegPath) } - $process = Start-Process -FilePath "powershell.exe" -ArgumentList $args -PassThru -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr + $shellPath = (Get-Process -Id $PID).Path + if (-not $shellPath) { $shellPath = "pwsh.exe" } + $process = Start-Process -FilePath $shellPath -ArgumentList $args -PassThru -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr return [pscustomobject]@{ jobId = $jobId pid = $process.Id @@ -118,6 +137,44 @@ function Start-SunoProcess { } } +function Get-ExistingSunoIds { + param([string]$Root) + + $ids = [System.Collections.Generic.HashSet[string]]::new() + if (-not (Test-Path -LiteralPath $Root)) { + return ,$ids + } + + Get-ChildItem -LiteralPath $Root -Recurse -Filter "*metadata.json" -File -ErrorAction SilentlyContinue | ForEach-Object { + try { + $json = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + $id = "$($json.id)".Trim() + if ($id) { [void]$ids.Add($id) } + } catch {} + } + + return ,$ids +} + +function Get-MissingTracks { + param($Tracks) + + $existingIds = Get-ExistingSunoIds $libraryRoot + $missing = @() + foreach ($track in @($Tracks)) { + $id = "$($track.id)".Trim() + if (-not $id) { continue } + if (-not $existingIds.Contains($id)) { + $missing += $track + } + } + + return [pscustomobject]@{ + existingIds = @($existingIds) + missing = $missing + } +} + if (-not (Test-Path -LiteralPath $processor)) { throw "processor not found: $processor" } @@ -127,6 +184,7 @@ $listener.Start() Write-Host "SUNO companion listening on http://127.0.0.1:$Port" Write-Host "OutputRoot: $OutputRoot" +Write-Host "Library: $libraryRoot" Write-Host "Logs: $logs" Write-Host "Press Ctrl+C to stop." @@ -144,7 +202,24 @@ try { if ($request.Method -eq "GET" -and $request.Path -eq "/health") { Write-Host "Health check OK: $(Get-Date -Format 'HH:mm:ss')" - Write-Response $stream 200 "OK" (@{ ok = $true; outputRoot = $OutputRoot } | ConvertTo-Json -Compress) + Write-Response $stream 200 "OK" (@{ ok = $true; outputRoot = $OutputRoot; libraryRoot = $libraryRoot; artist = $ArtistName } | ConvertTo-Json -Compress) + continue + } + + if ($request.Method -eq "POST" -and $request.Path -eq "/sync-index") { + $manifest = $request.Body | ConvertFrom-Json + $result = Get-MissingTracks $manifest.tracks + Write-Host "Sync check: $(@($manifest.tracks).Count) received, $(@($result.missing).Count) missing" + Write-Response $stream 200 "OK" (@{ + ok = $true + outputRoot = $OutputRoot + libraryRoot = $libraryRoot + artist = $ArtistName + received = @($manifest.tracks).Count + existing = @($result.existingIds).Count + missing = @($result.missing).Count + tracks = @($result.missing) + } | ConvertTo-Json -Depth 50 -Compress) continue } @@ -155,8 +230,21 @@ try { continue } + $sync = Get-MissingTracks $manifest.tracks + $manifest.tracks = @($sync.missing) + $manifest.count = @($manifest.tracks).Count + if ($manifest.count -eq 0) { + Write-Host "Process skipped: library already synced" + Write-Response $stream 200 "OK" (@{ + ok = $true + skipped = $true + message = "library already synced" + } | 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 + $manifest | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 $job = Start-SunoProcess $manifestPath Write-Host "Started job $($job.jobId) for $(@($manifest.tracks).Count) tracks"