543 lines
17 KiB
PowerShell
543 lines
17 KiB
PowerShell
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]]$ArgList,
|
|
[string]$Key,
|
|
[string]$Value
|
|
)
|
|
|
|
if ($Value) {
|
|
$ArgList.Add("-metadata")
|
|
$ArgList.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(" <track>")
|
|
$trackXml.Add(" <position>$position</position>")
|
|
$trackXml.Add(" <title>$(Escape-XmlText $title)</title>")
|
|
if ($duration.Text) { $trackXml.Add(" <duration>$($duration.Text)</duration>") }
|
|
$trackXml.Add(" </track>")
|
|
}
|
|
|
|
$runtime = if ($totalSeconds -gt 0) { [Math]::Ceiling($totalSeconds / 60) } else { 0 }
|
|
$lines = New-Object System.Collections.Generic.List[string]
|
|
$lines.Add('<?xml version="1.0" encoding="utf-8" standalone="yes"?>')
|
|
$lines.Add('<album>')
|
|
$lines.Add(' <review />')
|
|
$lines.Add(' <outline />')
|
|
$lines.Add(' <lockdata>false</lockdata>')
|
|
$lines.Add(" <dateadded>$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</dateadded>")
|
|
$lines.Add(" <title>$(Escape-XmlText $AlbumTitle)</title>")
|
|
if ($runtime -gt 0) { $lines.Add(" <runtime>$runtime</runtime>") }
|
|
if ($Genre) { $lines.Add(" <genre>$(Escape-XmlText $Genre)</genre>") }
|
|
$lines.Add(" <artist>$(Escape-XmlText $Artist)</artist>")
|
|
$lines.Add(" <albumartist>$(Escape-XmlText $Artist)</albumartist>")
|
|
foreach ($line in $trackXml) { $lines.Add($line) }
|
|
$lines.Add('</album>')
|
|
$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
|
|
$workRootName = ConvertTo-SafeName ([IO.Path]::GetFileNameWithoutExtension($manifestFile)) "work"
|
|
$workRoot = Join-Path (Join-Path $libraryRoot "_work") $workRootName
|
|
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")
|
|
$localAudioPath = Get-DeepValue $track @("localAudioPath", "local_audio_path", "uploadedAudioPath")
|
|
$localAudioExt = Get-DeepValue $track @("localAudioExt", "local_audio_ext", "audioExt")
|
|
$localWavPath = Get-DeepValue $track @("localWavPath", "local_wav_path", "uploadedWavPath")
|
|
if (-not $localAudioPath -and $localWavPath) {
|
|
$localAudioPath = $localWavPath
|
|
$localAudioExt = "wav"
|
|
}
|
|
if (-not $localAudioExt -and $localAudioPath) {
|
|
$localAudioExt = [IO.Path]::GetExtension($localAudioPath).TrimStart(".").ToLowerInvariant()
|
|
}
|
|
$outputExt = if ($localAudioExt -eq "mp3") { "mp3" } else { "flac" }
|
|
|
|
$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") }
|
|
|
|
$inputExt = if ($localAudioExt) { $localAudioExt } else { "wav" }
|
|
$inputAudio = Join-Path $workRoot "$prefix.input.$inputExt"
|
|
$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)
|
|
$outputFile = Join-Path $trackFolder ("{0}.{1}" -f $prefix, $outputExt)
|
|
|
|
Write-Host ""
|
|
Write-Host "[$index/$($tracks.Count)] $title"
|
|
|
|
if (-not $localAudioPath -and -not $wavCandidates.Count) {
|
|
Write-Warning "오디오 입력이 없어서 건너뜀: $title"
|
|
continue
|
|
}
|
|
|
|
$audioOk = $false
|
|
if ($localAudioPath -and (Test-Path -LiteralPath $localAudioPath)) {
|
|
Write-Host "업로드된 오디오 사용: $localAudioPath"
|
|
Copy-Item -LiteralPath $localAudioPath -Destination $inputAudio -Force
|
|
$audioOk = (Test-Path -LiteralPath $inputAudio) -and ((Get-Item -LiteralPath $inputAudio).Length -gt 0)
|
|
} else {
|
|
$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 -update 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)")
|
|
}
|
|
if ($outputExt -eq "mp3") {
|
|
$argsList.Add("-c:a")
|
|
$argsList.Add("copy")
|
|
$argsList.Add("-id3v2_version")
|
|
$argsList.Add("3")
|
|
$argsList.Add("-write_id3v1")
|
|
$argsList.Add("1")
|
|
} else {
|
|
$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($outputFile)
|
|
|
|
& $ffmpeg @argsList
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Warning "오디오 후처리 실패: $title"
|
|
continue
|
|
}
|
|
|
|
Write-Host "완료: $outputFile"
|
|
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"
|