Add local Suno FLAC processing workflow
This commit is contained in:
@@ -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를 메타데이터로 넣습니다.
|
||||
|
||||
@@ -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 @@
|
||||
<button class="cgs-btn cgs-wide" type="button" data-cgs-scan-library>라이브러리 곡 감지</button>
|
||||
</div>
|
||||
<div class="cgs-row">
|
||||
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-download-library>감지된 라이브러리 일괄 다운로드</button>
|
||||
<button class="cgs-btn cgs-primary cgs-wide" type="button" data-cgs-export-library>처리용 manifest 저장</button>
|
||||
</div>
|
||||
<div class="cgs-row">
|
||||
<button class="cgs-btn cgs-wide" type="button" data-cgs-download-library>브라우저 일괄 다운로드</button>
|
||||
</div>
|
||||
<div class="cgs-status"></div>
|
||||
`;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
@@ -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를 누르면 닫힘"
|
||||
Reference in New Issue
Block a user