Add clip-id based Suno library sync

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-01 23:10:57 +09:00
parent cbcb37ddb5
commit 1414c38bd1
7 changed files with 254 additions and 30 deletions
+3
View File
@@ -0,0 +1,3 @@
companion-inbox/
companion-logs/
_work/
+12 -2
View File
@@ -63,9 +63,19 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책
기본 출력 위치: 기본 출력 위치:
```text ```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` 폴더에 저장됩니다. 로그는 `companion-logs` 폴더에 저장됩니다.
+32 -5
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name ChatGPT to Suno Prompt Copier // @name ChatGPT to Suno Prompt Copier
// @namespace local.suno-helper // @namespace local.suno-helper
// @version 0.5.2 // @version 0.6.0
// @description Copy structured ChatGPT song prompt versions into Suno fields. // @description Copy structured ChatGPT song prompt versions into Suno fields.
// @author local // @author local
// @match https://chatgpt.com/* // @match https://chatgpt.com/*
@@ -952,10 +952,33 @@
setStatus(statusTarget, "보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘."); setStatus(statusTarget, "보낼 곡을 못 찾았어. 곡 감지를 먼저 해줘.");
return; 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 = []; const preparedTracks = [];
for (let index = 0; index < tracks.length; index += 1) { for (let index = 0; index < missingTracks.length; index += 1) {
const track = { ...tracks[index] }; const track = { ...missingTracks[index] };
setStatus(statusTarget, `${index + 1}/${tracks.length} WAV 준비 중: ${track.title || track.id}`); setStatus(statusTarget, `${index + 1}/${missingTracks.length} 없는 곡 WAV 준비 중: ${track.title || track.id}`);
track.wavUrl = await requestSunoWavUrl(track); track.wavUrl = await requestSunoWavUrl(track);
preparedTracks.push(track); preparedTracks.push(track);
} }
@@ -975,7 +998,11 @@
throw new Error(text || `${response.status} ${response.statusText}`); throw new Error(text || `${response.status} ${response.statusText}`);
} }
const data = tryParseJson(text) || {}; 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) { async function testCompanion(statusTarget) {
+108 -16
View File
@@ -1,8 +1,10 @@
param( param(
[string]$ManifestPath = "", [string]$ManifestPath = "",
[string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨\SUNO", [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨",
[string]$ArtistName = "SUNO",
[string]$FfmpegPath = "", [string]$FfmpegPath = "",
[switch]$KeepWorkFiles [switch]$KeepWorkFiles,
[switch]$Force
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -64,6 +66,70 @@ function ConvertTo-SafeName {
return $name 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 { function Get-ObjectValues {
param($Value) param($Value)
@@ -167,13 +233,13 @@ if (-not $tracks.Count) {
throw "manifest에 tracks 배열이 없어." throw "manifest에 tracks 배열이 없어."
} }
$dateFolder = Get-Date -Format "yyyy-MM-dd" $libraryRoot = Resolve-LibraryRoot $OutputRoot $ArtistName
$targetRoot = Join-Path $OutputRoot $dateFolder New-Item -ItemType Directory -Force -Path $libraryRoot | Out-Null
$workRoot = Join-Path $targetRoot "_work" $workRoot = Join-Path $libraryRoot "_work"
New-Item -ItemType Directory -Force -Path $targetRoot, $workRoot | Out-Null New-Item -ItemType Directory -Force -Path $workRoot | Out-Null
Write-Host "Manifest: $manifestFile" Write-Host "Manifest: $manifestFile"
Write-Host "Output: $targetRoot" Write-Host "Output: $libraryRoot"
Write-Host "ffmpeg: $ffmpeg" Write-Host "ffmpeg: $ffmpeg"
Write-Host "Tracks: $($tracks.Count)" Write-Host "Tracks: $($tracks.Count)"
@@ -183,8 +249,18 @@ foreach ($track in $tracks) {
$id = if ($track.id) { "$($track.id)" } else { "" } $id = if ($track.id) { "$($track.id)" } else { "" }
$title = if ($track.title) { "$($track.title)" } else { $id } $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 $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") $lyrics = Get-DeepValue $track @("prompt", "lyrics", "lyric", "text")
$style = Get-DeepValue $track @("gpt_description_prompt", "style", "tags", "description") $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") } $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" $inputAudio = Join-Path $workRoot "$prefix.input.wav"
$coverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')" $rawCoverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')"
$metadataFile = Join-Path $targetRoot "$prefix.metadata.json" $coverFile = Join-Path $trackFolder "cover.jpg"
$flacFile = Join-Path $targetRoot "$prefix.flac" $metadataFile = Join-Path $trackFolder "metadata.json"
$flacFile = Join-Path $trackFolder ("01 {0}.flac" -f $safeTitle)
Write-Host "" Write-Host ""
Write-Host "[$index/$($tracks.Count)] $title" Write-Host "[$index/$($tracks.Count)] $title"
@@ -219,7 +296,15 @@ foreach ($track in $tracks) {
$coverOk = $false $coverOk = $false
if ($imageUrl) { 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 $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("attached_pic")
$argsList.Add("-c:v") $argsList.Add("-c:v")
$argsList.Add("copy") $argsList.Add("copy")
$argsList.Add("-metadata:s:v")
$argsList.Add("comment=Cover (front)")
} }
$argsList.Add("-c:a") $argsList.Add("-c:a")
$argsList.Add("flac") $argsList.Add("flac")
$argsList.Add("-compression_level") $argsList.Add("-compression_level")
$argsList.Add("8") $argsList.Add("8")
Add-MetadataArg $argsList "title" $title Add-MetadataArg $argsList "title" $title
Add-MetadataArg $argsList "artist" "SUNO" Add-MetadataArg $argsList "artist" $ArtistName
Add-MetadataArg $argsList "album" "SUNO" 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 "genre" $style
Add-MetadataArg $argsList "lyrics" $lyrics Add-MetadataArg $argsList "lyrics-XXX" $lyrics
Add-MetadataArg $argsList "description" $style Add-MetadataArg $argsList "description" $style
Add-MetadataArg $argsList "comment" "Suno clip id: $id" Add-MetadataArg $argsList "comment" "Suno clip id: $id"
Add-MetadataArg $argsList "date" $createdAt Add-MetadataArg $argsList "date" $createdAt
Add-MetadataArg $argsList "encoder" "Codex SUNO helper" Add-MetadataArg $argsList "encoder" "Codex SUNO helper"
Add-MetadataArg $argsList "suno_model" $model Add-MetadataArg $argsList "suno_model" $model
Add-MetadataArg $argsList "TMED" "Digital Media"
$argsList.Add($flacFile) $argsList.Add($flacFile)
& $ffmpeg @argsList & $ffmpeg @argsList
@@ -271,4 +363,4 @@ if (-not $KeepWorkFiles -and (Test-Path -LiteralPath $workRoot)) {
} }
Write-Host "" Write-Host ""
Write-Host "처리 완료: $targetRoot" Write-Host "처리 완료: $libraryRoot"
+4 -1
View File
@@ -12,7 +12,10 @@ if (-not $latest) {
exit 1 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 "" Write-Host ""
Read-Host "Enter를 누르면 닫힘" Read-Host "Enter를 누르면 닫힘"
+2 -1
View File
@@ -2,6 +2,7 @@ $ErrorActionPreference = "Stop"
& "$PSScriptRoot\suno-companion.ps1" ` & "$PSScriptRoot\suno-companion.ps1" `
-Port 17873 ` -Port 17873 `
-OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨\SUNO" -OutputRoot "\\VaultOfData\AKAMedia\음악\처리됨" `
-ArtistName "SUNO"
Read-Host "Enter를 누르면 닫힘" Read-Host "Enter를 누르면 닫힘"
+93 -5
View File
@@ -1,6 +1,7 @@
param( param(
[int]$Port = 17873, [int]$Port = 17873,
[string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨\SUNO", [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨",
[string]$ArtistName = "SUNO",
[string]$FfmpegPath = "" [string]$FfmpegPath = ""
) )
@@ -12,6 +13,21 @@ $inbox = Join-Path $scriptRoot "companion-inbox"
$logs = Join-Path $scriptRoot "companion-logs" $logs = Join-Path $scriptRoot "companion-logs"
New-Item -ItemType Directory -Force -Path $inbox, $logs | Out-Null 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 { function Write-Response {
param( param(
[System.Net.Sockets.NetworkStream]$Stream, [System.Net.Sockets.NetworkStream]$Stream,
@@ -103,13 +119,16 @@ function Start-SunoProcess {
"-ExecutionPolicy", "Bypass", "-ExecutionPolicy", "Bypass",
"-File", $processor, "-File", $processor,
"-ManifestPath", $ManifestPath, "-ManifestPath", $ManifestPath,
"-OutputRoot", $OutputRoot "-OutputRoot", $OutputRoot,
"-ArtistName", $ArtistName
) )
if ($FfmpegPath) { if ($FfmpegPath) {
$args += @("-FfmpegPath", $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]@{ return [pscustomobject]@{
jobId = $jobId jobId = $jobId
pid = $process.Id 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)) { if (-not (Test-Path -LiteralPath $processor)) {
throw "processor not found: $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 "SUNO companion listening on http://127.0.0.1:$Port"
Write-Host "OutputRoot: $OutputRoot" Write-Host "OutputRoot: $OutputRoot"
Write-Host "Library: $libraryRoot"
Write-Host "Logs: $logs" Write-Host "Logs: $logs"
Write-Host "Press Ctrl+C to stop." Write-Host "Press Ctrl+C to stop."
@@ -144,7 +202,24 @@ try {
if ($request.Method -eq "GET" -and $request.Path -eq "/health") { if ($request.Method -eq "GET" -and $request.Path -eq "/health") {
Write-Host "Health check OK: $(Get-Date -Format 'HH:mm:ss')" 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 continue
} }
@@ -155,8 +230,21 @@ try {
continue 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")) $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 $job = Start-SunoProcess $manifestPath
Write-Host "Started job $($job.jobId) for $(@($manifest.tracks).Count) tracks" Write-Host "Started job $($job.jobId) for $(@($manifest.tracks).Count) tracks"