param( [string]$ManifestPath = "", [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨", [string]$ArtistName = "SUNO", [string]$FfmpegPath = "", [int]$WavRetrySeconds = 300, [int]$WavRetryIntervalSeconds = 8, [switch]$KeepWorkFiles, [switch]$Force ) $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-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) 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-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 return Join-Path $Root $safeTitle } function Get-AlbumTitle { param( [string]$Title, [string]$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 { 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 "*.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) 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 Resolve-SunoMediaUrl { param([string]$Value) if (-not $Value) { return "" } $raw = $Value.Trim() if ($raw -match '^https?://') { return $raw } if ($raw.StartsWith("//")) { return "https:$raw" } if ($raw.StartsWith("/")) { return "https://suno.com$raw" } return $raw } 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 Invoke-DownloadWithRetry { param( [string[]]$Urls, [string]$OutFile, [int]$RetrySeconds, [int]$IntervalSeconds ) $candidates = @($Urls | Where-Object { $_ } | Select-Object -Unique) if (-not $candidates.Count) { return $false } $deadline = (Get-Date).AddSeconds([Math]::Max(1, $RetrySeconds)) $attempt = 0 do { $attempt++ foreach ($candidate in $candidates) { Write-Host "WAV 후보 시도 #$attempt`: $candidate" if (Invoke-Download $candidate $OutFile) { return $true } } if ((Get-Date) -lt $deadline) { Write-Host "WAV 아직 준비 안 됨. $IntervalSeconds초 후 재시도..." Start-Sleep -Seconds ([Math]::Max(1, $IntervalSeconds)) } } while ((Get-Date) -lt $deadline) 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") } } 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(" ") $trackXml.Add(" $position") $trackXml.Add(" $(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) if (-not $tracks.Count) { throw "manifest에 tracks 배열이 없어." } $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: $libraryRoot" Write-Host "ffmpeg: $ffmpeg" Write-Host "ffprobe: $ffprobe" 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 } 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 $safeTitle = ConvertTo-SafeName $title $id $shortId = Get-ShortId $id $trackNo = [int](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") $model = Get-DeepValue $track @("model_name", "model", "major_model_version") $createdAt = Get-DeepValue $track @("created_at", "createdAt") $wavUrl = Resolve-SunoMediaUrl $(if ($track.wavUrl) { "$($track.wavUrl)" } else { Get-DeepValue $track @("wav_url", "wavUrl", "download_url", "downloadUrl") }) $wavCandidates = @() if ($wavUrl) { $wavCandidates += $wavUrl } $wavCandidates = $wavCandidates | Where-Object { $_ } | Select-Object -Unique $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" $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.{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" if (-not $wavCandidates.Count) { Write-Warning "WAV URL이 없어서 건너뜀: $title" continue } $audioOk = Invoke-DownloadWithRetry $wavCandidates $inputAudio $WavRetrySeconds $WavRetryIntervalSeconds if (-not $audioOk) { Write-Warning "WAV를 받지 못해서 건너뜀: $title" continue } New-Item -ItemType Directory -Force -Path $trackFolder | Out-Null $coverOk = $false if ($imageUrl) { $coverDownloaded = Invoke-Download $imageUrl $rawCoverFile if ($coverDownloaded) { & $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 $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") $argsList.Add("-y") $argsList.Add("-i") $argsList.Add($inputAudio) if ($coverOk) { $argsList.Add("-i") $argsList.Add($trackCoverFile) $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("-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" $ArtistName Add-MetadataArg $argsList "ALBUM" $albumTitle Add-MetadataArg $argsList "album_artist" $ArtistName Add-MetadataArg $argsList "ARTISTS" $ArtistName Add-MetadataArg $argsList "track" "$trackNo" Add-MetadataArg $argsList "disc" "1/1" 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 "SUNO_CLIP_ID" $id Add-MetadataArg $argsList "TMED" "Digital Media" $argsList.Add($flacFile) & $ffmpeg @argsList if ($LASTEXITCODE -ne 0) { Write-Warning "FLAC 변환 실패: $title" continue } Write-Host "완료: $flacFile" Write-AlbumNfo $trackFolder $albumTitle $ArtistName $style $ffprobe } if (-not $KeepWorkFiles -and (Test-Path -LiteralPath $workRoot)) { Remove-Item -LiteralPath $workRoot -Recurse -Force } Write-Host "" Write-Host "처리 완료: $libraryRoot"