From b769b62b19f0915775d176ee7c19c3060509ddb4 Mon Sep 17 00:00:00 2001 From: "DESKTOP-KSVGT20\\shkim" Date: Fri, 1 May 2026 23:31:11 +0900 Subject: [PATCH] Align Suno metadata with music library layout --- README.md | 12 ++- normalize-existing-suno-library.ps1 | 115 ++++++++++++++++++++ process-suno-library.ps1 | 158 ++++++++++++++++++++++++---- suno-companion.ps1 | 2 +- 4 files changed, 259 insertions(+), 28 deletions(-) create mode 100644 normalize-existing-suno-library.ps1 diff --git a/README.md b/README.md index 8e0216a..c7c5850 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,15 @@ WAV 다운로드는 SUNO 계정 권한, 변환 준비 상태, CDN 접근 정책 정리 포맷: ```text -\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목 [clipId앞12자리]\01 곡 제목.flac -\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목 [clipId앞12자리]\cover.jpg -\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목 [clipId앞12자리]\metadata.json +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목\01 곡 제목.flac +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목\01 곡 제목.lrc +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목\cover.jpg +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목\album.nfo +\\VaultOfData\AKAMedia\음악\처리됨\SUNO\곡 제목\metadata.clipId앞12자리.json ``` -동기화 비교 키는 곡 제목이 아니라 SUNO clip id입니다. 같은 제목의 곡이 여러 개 있어도 `metadata.json`의 `id`와 폴더명의 id 조각으로 구분합니다. +동기화 비교 키는 곡 제목이 아니라 SUNO clip id입니다. 같은 제목의 곡이 여러 개 있어도 폴더명/앨범명은 깨끗하게 `곡 제목`으로 유지하고, `metadata.*.json`의 `id`와 FLAC 내부 comment 태그로 구분합니다. -로컬 처리기는 manifest에 저장된 WAV URL만 다운로드합니다. MP3/audio fallback은 사용하지 않습니다. 이후 ffmpeg로 FLAC 변환하면서 기존 라이브러리 방식에 맞춰 title, artist, album, album_artist, track, disc, genre/style, lyrics-XXX, Suno clip id, cover art를 메타데이터로 넣습니다. +로컬 처리기는 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/normalize-existing-suno-library.ps1 b/normalize-existing-suno-library.ps1 new file mode 100644 index 0000000..d15f93f --- /dev/null +++ b/normalize-existing-suno-library.ps1 @@ -0,0 +1,115 @@ +param( + [string]$LibraryRoot = "\\VaultOfData\AKAMedia\음악\처리됨\SUNO", + [switch]$Apply +) + +$ErrorActionPreference = "Stop" + +function ConvertTo-SafeName { + param([string]$Value) + + $name = if ($Value) { $Value } else { "suno-track" } + $name = $name -replace '[\\/:*?"<>|]+', ' ' + $name = $name -replace '\s+', ' ' + $name = $name.Trim() + if (-not $name) { $name = "suno-track" } + if ($name.Length -gt 120) { $name = $name.Substring(0, 120).Trim() } + 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-NextTrackNumber { + param([string]$Folder) + + if (-not (Test-Path -LiteralPath $Folder)) { return 1 } + $numbers = Get-ChildItem -LiteralPath $Folder -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in ".flac", ".mp3", ".m4a" } | + ForEach-Object { + if ($_.BaseName -match '^(\d{2})\s') { [int]$Matches[1] } + } + if (-not $numbers) { return 1 } + return (($numbers | Measure-Object -Maximum).Maximum + 1) +} + +if (-not (Test-Path -LiteralPath $LibraryRoot)) { + throw "SUNO library root not found: $LibraryRoot" +} + +$candidateDirs = Get-ChildItem -LiteralPath $LibraryRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^(?.+)\s\[[0-9a-fA-F-]{8,}\]$' } + +foreach ($dir in $candidateDirs) { + $metadata = Get-ChildItem -LiteralPath $dir.FullName -File -Filter "*.json" -ErrorAction SilentlyContinue | + ForEach-Object { + try { + $json = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + if ($json.id) { + [pscustomobject]@{ File = $_; Json = $json } + } + } catch {} + } | + Select-Object -First 1 + + if (-not $metadata) { + Write-Host "SKIP no metadata id: $($dir.FullName)" + continue + } + + $id = "$($metadata.Json.id)" + $title = ConvertTo-SafeName "$($metadata.Json.title)" + if (-not $title -and $dir.Name -match '^(?<title>.+)\s\[[0-9a-fA-F-]{8,}\]$') { + $title = ConvertTo-SafeName $Matches.title + } + $shortId = Get-ShortId $id + $targetDir = Join-Path $LibraryRoot $title + $trackNo = Get-NextTrackNumber $targetDir + $audio = Get-ChildItem -LiteralPath $dir.FullName -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in ".flac", ".mp3", ".m4a" } | + Select-Object -First 1 + $cover = Get-ChildItem -LiteralPath $dir.FullName -File -Filter "cover.*" -ErrorAction SilentlyContinue | Select-Object -First 1 + + Write-Host "" + Write-Host "FROM: $($dir.FullName)" + Write-Host "TO: $targetDir" + Write-Host "ID: $id" + + if (-not $Apply) { + Write-Host "DRY: would move audio/metadata/cover" + continue + } + + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + + if ($audio) { + $newAudio = Join-Path $targetDir ("{0:D2} {1}{2}" -f $trackNo, $title, $audio.Extension) + Move-Item -LiteralPath $audio.FullName -Destination $newAudio -Force + Write-Host "audio -> $newAudio" + } + + $newMetadata = Join-Path $targetDir ("metadata.{0}.json" -f $shortId) + Move-Item -LiteralPath $metadata.File.FullName -Destination $newMetadata -Force + Write-Host "metadata -> $newMetadata" + + if ($cover) { + $coverTarget = Join-Path $targetDir "cover.jpg" + if (-not (Test-Path -LiteralPath $coverTarget)) { + Move-Item -LiteralPath $cover.FullName -Destination $coverTarget -Force + Write-Host "cover -> $coverTarget" + } + } + + $remaining = Get-ChildItem -LiteralPath $dir.FullName -Force -ErrorAction SilentlyContinue + if (-not $remaining) { + Remove-Item -LiteralPath $dir.FullName -Force + Write-Host "removed empty old folder" + } else { + Write-Host "old folder kept; remaining files exist" + } +} diff --git a/process-suno-library.ps1 b/process-suno-library.ps1 index 0e84550..8b8ff99 100644 --- a/process-suno-library.ps1 +++ b/process-suno-library.ps1 @@ -29,6 +29,16 @@ function Resolve-Ffmpeg { throw "ffmpeg.exe를 찾지 못했어. -FfmpegPath 로 경로를 지정해줘." } +function Resolve-Ffprobe { + param([string]$Ffmpeg) + + $candidate = Join-Path (Split-Path -Parent $Ffmpeg) "ffprobe.exe" + if (Test-Path -LiteralPath $candidate) { return $candidate } + $cmd = Get-Command ffprobe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + return "" +} + function Resolve-Manifest { param([string]$Path) @@ -82,8 +92,7 @@ function Get-TrackFolder { ) $safeTitle = ConvertTo-SafeName $Title $Id - $shortId = Get-ShortId $Id - return Join-Path $Root ("{0} [{1}]" -f $safeTitle, $shortId) + return Join-Path $Root $safeTitle } function Get-AlbumTitle { @@ -92,7 +101,20 @@ function Get-AlbumTitle { [string]$Id ) - return "{0} [{1}]" -f (ConvertTo-SafeName $Title $Id), (Get-ShortId $Id) + return ConvertTo-SafeName $Title $Id +} + +function Get-NextTrackNumber { + param([string]$Folder) + + if (-not (Test-Path -LiteralPath $Folder)) { return 1 } + $numbers = Get-ChildItem -LiteralPath $Folder -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in ".flac", ".mp3", ".m4a" } | + ForEach-Object { + if ($_.BaseName -match '^(\d{2})\s') { [int]$Matches[1] } + } + if (-not $numbers) { return 1 } + return (($numbers | Measure-Object -Maximum).Maximum + 1) } function Resolve-LibraryRoot { @@ -116,7 +138,7 @@ function Test-ExistingTrack { if (-not $Id -or -not (Test-Path -LiteralPath $Root)) { return $false } - $metadataHit = Get-ChildItem -LiteralPath $Root -Recurse -Filter "*metadata.json" -File -ErrorAction SilentlyContinue | + $metadataHit = Get-ChildItem -LiteralPath $Root -Recurse -Filter "*.json" -File -ErrorAction SilentlyContinue | Where-Object { try { $json = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json @@ -224,7 +246,87 @@ function Add-MetadataArg { } } +function Escape-XmlText { + param([string]$Value) + + return [System.Security.SecurityElement]::Escape($Value) +} + +function Get-AudioDuration { + param( + [string]$File, + [string]$Ffprobe + ) + + if (-not $Ffprobe -or -not (Test-Path -LiteralPath $File)) { + return [pscustomobject]@{ Seconds = 0; Text = "" } + } + + try { + $raw = & $Ffprobe -v quiet -print_format json -show_format $File + $json = $raw | ConvertFrom-Json + $seconds = [double]$json.format.duration + $span = [TimeSpan]::FromSeconds([Math]::Round($seconds)) + return [pscustomobject]@{ + Seconds = $seconds + Text = "{0:D2}:{1:D2}" -f [int]$span.TotalMinutes, $span.Seconds + } + } catch { + return [pscustomobject]@{ Seconds = 0; Text = "" } + } +} + +function Write-AlbumNfo { + param( + [string]$Folder, + [string]$AlbumTitle, + [string]$Artist, + [string]$Genre, + [string]$Ffprobe + ) + + $tracks = Get-ChildItem -LiteralPath $Folder -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -in ".flac", ".mp3", ".m4a" } | + Sort-Object Name + + $trackXml = New-Object System.Collections.Generic.List[string] + $totalSeconds = 0 + foreach ($file in $tracks) { + $position = 1 + $title = [IO.Path]::GetFileNameWithoutExtension($file.Name) + if ($title -match '^(\d{2})\s+(.+)$') { + $position = [int]$Matches[1] + $title = $Matches[2] + } + $duration = Get-AudioDuration $file.FullName $Ffprobe + $totalSeconds += $duration.Seconds + $trackXml.Add(" <track>") + $trackXml.Add(" <position>$position</position>") + $trackXml.Add(" <title>$(Escape-XmlText $title)") + if ($duration.Text) { $trackXml.Add(" $($duration.Text)") } + $trackXml.Add(" ") + } + + $runtime = if ($totalSeconds -gt 0) { [Math]::Ceiling($totalSeconds / 60) } else { 0 } + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('') + $lines.Add('') + $lines.Add(' ') + $lines.Add(' ') + $lines.Add(' false') + $lines.Add(" $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + $lines.Add(" $(Escape-XmlText $AlbumTitle)") + if ($runtime -gt 0) { $lines.Add(" $runtime") } + if ($Genre) { $lines.Add(" $(Escape-XmlText $Genre)") } + $lines.Add(" $(Escape-XmlText $Artist)") + $lines.Add(" $(Escape-XmlText $Artist)") + foreach ($line in $trackXml) { $lines.Add($line) } + $lines.Add('') + $lines | Set-Content -LiteralPath (Join-Path $Folder "album.nfo") -Encoding UTF8 +} + $ffmpeg = Resolve-Ffmpeg $FfmpegPath +$ffprobe = Resolve-Ffprobe $ffmpeg $manifestFile = Resolve-Manifest $ManifestPath $manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json $tracks = @($manifest.tracks) @@ -241,6 +343,7 @@ New-Item -ItemType Directory -Force -Path $workRoot | Out-Null Write-Host "Manifest: $manifestFile" Write-Host "Output: $libraryRoot" Write-Host "ffmpeg: $ffmpeg" +Write-Host "ffprobe: $ffprobe" Write-Host "Tracks: $($tracks.Count)" $index = 0 @@ -260,7 +363,8 @@ foreach ($track in $tracks) { New-Item -ItemType Directory -Force -Path $trackFolder | Out-Null $safeTitle = ConvertTo-SafeName $title $id $shortId = Get-ShortId $id - $prefix = "{0} [{1}]" -f $safeTitle, $shortId + $trackNo = Get-NextTrackNumber $trackFolder + $prefix = "{0:D2} {1}" -f $trackNo, $safeTitle $lyrics = Get-DeepValue $track @("prompt", "lyrics", "lyric", "text") $style = Get-DeepValue $track @("gpt_description_prompt", "style", "tags", "description") @@ -276,9 +380,11 @@ foreach ($track in $tracks) { $inputAudio = Join-Path $workRoot "$prefix.input.wav" $rawCoverFile = Join-Path $workRoot "$prefix.cover.$(Get-UrlExtension $imageUrl 'jpg')" + $trackCoverFile = Join-Path $workRoot "$prefix.embed-cover.jpg" $coverFile = Join-Path $trackFolder "cover.jpg" - $metadataFile = Join-Path $trackFolder "metadata.json" - $flacFile = Join-Path $trackFolder ("01 {0}.flac" -f $safeTitle) + $metadataFile = Join-Path $trackFolder ("metadata.{0}.json" -f $shortId) + $lyricsFile = Join-Path $trackFolder ("{0}.lrc" -f $prefix) + $flacFile = Join-Path $trackFolder ("{0}.flac" -f $prefix) Write-Host "" Write-Host "[$index/$($tracks.Count)] $title" @@ -298,16 +404,22 @@ foreach ($track in $tracks) { if ($imageUrl) { $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) + & $ffmpeg -hide_banner -y -i $rawCoverFile -frames:v 1 $trackCoverFile + $coverOk = ($LASTEXITCODE -eq 0) -and (Test-Path -LiteralPath $trackCoverFile) if (-not $coverOk) { - Copy-Item -LiteralPath $rawCoverFile -Destination $coverFile -Force - $coverOk = Test-Path -LiteralPath $coverFile + Copy-Item -LiteralPath $rawCoverFile -Destination $trackCoverFile -Force + $coverOk = Test-Path -LiteralPath $trackCoverFile + } + if ($coverOk -and -not (Test-Path -LiteralPath $coverFile)) { + Copy-Item -LiteralPath $trackCoverFile -Destination $coverFile -Force } } } $track | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $metadataFile -Encoding UTF8 + if ($lyrics) { + $lyrics | Set-Content -LiteralPath $lyricsFile -Encoding UTF8 + } $argsList = [System.Collections.Generic.List[string]]::new() $argsList.Add("-hide_banner") @@ -316,7 +428,7 @@ foreach ($track in $tracks) { $argsList.Add($inputAudio) if ($coverOk) { $argsList.Add("-i") - $argsList.Add($coverFile) + $argsList.Add($trackCoverFile) $argsList.Add("-map") $argsList.Add("0:a") $argsList.Add("-map") @@ -332,20 +444,21 @@ foreach ($track in $tracks) { $argsList.Add("flac") $argsList.Add("-compression_level") $argsList.Add("8") - Add-MetadataArg $argsList "title" $title - Add-MetadataArg $argsList "artist" $ArtistName - Add-MetadataArg $argsList "album" $albumTitle + Add-MetadataArg $argsList "TITLE" $title + 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 "track" "$trackNo" Add-MetadataArg $argsList "disc" "1/1" - Add-MetadataArg $argsList "genre" $style + Add-MetadataArg $argsList "GENRE" $style 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 "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 "SUNO_CLIP_ID" $id Add-MetadataArg $argsList "TMED" "Digital Media" $argsList.Add($flacFile) @@ -356,6 +469,7 @@ foreach ($track in $tracks) { } Write-Host "완료: $flacFile" + Write-AlbumNfo $trackFolder $albumTitle $ArtistName $style $ffprobe } if (-not $KeepWorkFiles -and (Test-Path -LiteralPath $workRoot)) { diff --git a/suno-companion.ps1 b/suno-companion.ps1 index d9d6ff8..b986aac 100644 --- a/suno-companion.ps1 +++ b/suno-companion.ps1 @@ -222,7 +222,7 @@ function Get-ExistingSunoIds { return ,$ids } - Get-ChildItem -LiteralPath $Root -Recurse -Filter "*metadata.json" -File -ErrorAction SilentlyContinue | ForEach-Object { + Get-ChildItem -LiteralPath $Root -Recurse -Filter "*.json" -File -ErrorAction SilentlyContinue | ForEach-Object { try { $json = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json $id = "$($json.id)".Trim()