param( [int]$Port = 17873, [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨", [string]$ArtistName = "SUNO", [string]$FfmpegPath = "", [int]$MaxWorkers = 3 ) $ErrorActionPreference = "Stop" $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $processor = Join-Path $scriptRoot "process-suno-library.ps1" $inbox = Join-Path $scriptRoot "companion-inbox" $logs = Join-Path $scriptRoot "companion-logs" New-Item -ItemType Directory -Force -Path $inbox, $logs | Out-Null $queue = [System.Collections.Queue]::new() $queueIds = [System.Collections.Generic.HashSet[string]]::new() $activeJobs = [System.Collections.ArrayList]::new() $activeIds = [System.Collections.Generic.HashSet[string]]::new() $stats = [ordered]@{ total = 0 completed = 0 failed = 0 skipped = 0 } $lastProgressAt = [DateTime]::MinValue $lastProgressText = "" $lastDashboardAt = [DateTime]::MinValue 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 { param( [System.Net.Sockets.NetworkStream]$Stream, [int]$StatusCode, [string]$StatusText, [string]$Body, [string]$ContentType = "application/json; charset=utf-8" ) $bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body) $header = @( "HTTP/1.1 $StatusCode $StatusText", "Content-Type: $ContentType", "Content-Length: $($bodyBytes.Length)", "Access-Control-Allow-Origin: *", "Access-Control-Allow-Methods: GET, POST, OPTIONS", "Access-Control-Allow-Headers: content-type", "Access-Control-Allow-Private-Network: true", "Connection: close", "", "" ) -join "`r`n" $headerBytes = [Text.Encoding]::ASCII.GetBytes($header) $Stream.Write($headerBytes, 0, $headerBytes.Length) $Stream.Write($bodyBytes, 0, $bodyBytes.Length) } function Read-HttpRequest { param([System.Net.Sockets.NetworkStream]$Stream) $buffer = New-Object byte[] 65536 $received = [System.IO.MemoryStream]::new() $headerEnd = -1 while ($headerEnd -lt 0) { $read = $Stream.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } $received.Write($buffer, 0, $read) $text = [Text.Encoding]::ASCII.GetString($received.ToArray()) $headerEnd = $text.IndexOf("`r`n`r`n", [StringComparison]::Ordinal) if ($received.Length -gt 1048576) { throw "HTTP header too large" } } if ($headerEnd -lt 0) { throw "Invalid HTTP request" } $allBytes = $received.ToArray() $headerText = [Text.Encoding]::ASCII.GetString($allBytes, 0, $headerEnd) $lines = $headerText -split "`r`n" $requestLine = $lines[0] -split " " $headers = @{} foreach ($line in $lines | Select-Object -Skip 1) { $idx = $line.IndexOf(":") if ($idx -gt 0) { $headers[$line.Substring(0, $idx).Trim().ToLowerInvariant()] = $line.Substring($idx + 1).Trim() } } $contentLength = 0 if ($headers.ContainsKey("content-length")) { $contentLength = [int]$headers["content-length"] } $bodyStart = $headerEnd + 4 $bodyStream = [System.IO.MemoryStream]::new() $initialBodyLength = $allBytes.Length - $bodyStart if ($initialBodyLength -gt 0) { $bodyStream.Write($allBytes, $bodyStart, $initialBodyLength) } while ($bodyStream.Length -lt $contentLength) { $remaining = [int]([Math]::Min($buffer.Length, $contentLength - $bodyStream.Length)) $read = $Stream.Read($buffer, 0, $remaining) if ($read -le 0) { break } $bodyStream.Write($buffer, 0, $read) } return [pscustomobject]@{ Method = $requestLine[0] Path = $requestLine[1] Headers = $headers Body = [Text.Encoding]::UTF8.GetString($bodyStream.ToArray()) } } function Start-SunoProcess { param( [string]$ManifestPath, [string]$TrackId ) $shortId = if ($TrackId) { "$TrackId".Substring(0, [Math]::Min(8, "$TrackId".Length)) } else { "track" } $jobId = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmss-fff"), $shortId $stdout = Join-Path $logs "$jobId.out.log" $stderr = Join-Path $logs "$jobId.err.log" $args = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $processor, "-ManifestPath", $ManifestPath, "-OutputRoot", $OutputRoot, "-ArtistName", $ArtistName ) if ($FfmpegPath) { $args += @("-FfmpegPath", $FfmpegPath) } $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]@{ jobId = $jobId pid = $process.Id process = $process log = $stdout errorLog = $stderr } } function Save-Manifest { param($Manifest) $firstTrack = @($Manifest.tracks)[0] $id = "$($firstTrack.id)".Trim() $safeId = if ($id) { $id } else { Get-Date -Format "yyyyMMdd-HHmmssfff" } $manifestPath = Join-Path $inbox ("suno-track-{0}.manifest.json" -f $safeId) $Manifest | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 return $manifestPath } function Get-LastLogLine { param([string[]]$Paths) foreach ($path in $Paths) { if (-not (Test-Path -LiteralPath $path)) { continue } $line = Get-Content -LiteralPath $path -Tail 20 -ErrorAction SilentlyContinue | Where-Object { "$_".Trim() } | Select-Object -Last 1 if ($line) { return "$line" } } return "" } function Get-JobLine { param($Job) if (-not $Job) { return "" } return Get-LastLogLine @($Job.ErrorLog, $Job.Log) } function Publish-JobProgress { param($Job, [switch]$Force) if (-not $Job) { return } $now = Get-Date if (-not $Force -and ($now - $script:lastProgressAt).TotalSeconds -lt 10) { return } $line = Get-JobLine $Job if (-not $line) { return } $fingerprint = "$($Job.JobId):$line" if (-not $Force -and $fingerprint -eq $script:lastProgressText) { return } $script:lastProgressAt = $now $script:lastProgressText = $fingerprint Write-Host "[$($Job.JobId)] $($Job.Title): $line" } function Get-JobSnapshot { param($Job) $elapsed = [int]((Get-Date) - $Job.StartedAt).TotalSeconds return [pscustomobject]@{ jobId = $Job.JobId pid = $Job.Process.Id trackId = $Job.TrackId title = $Job.Title elapsedSeconds = $elapsed log = Get-JobLine $Job exitCode = if ($Job.Process.HasExited) { $Job.Process.ExitCode } else { $null } } } function Get-QueueSnapshot { $active = @($script:activeJobs | ForEach-Object { Get-JobSnapshot $_ }) $total = [int]$script:stats.total $done = [int]$script:stats.completed + [int]$script:stats.failed $percent = if ($total -gt 0) { [Math]::Round(($done / $total) * 100, 1) } else { 100 } $next = @($script:queue | Select-Object -First 5 | ForEach-Object { [pscustomobject]@{ id = $_.Id title = $_.Title } }) return [pscustomobject]@{ maxWorkers = $MaxWorkers queued = $script:queue.Count activeCount = $active.Count active = if ($active.Count) { ($active | Select-Object -First 1).title } else { "" } activeJobs = $active next = $next total = $total completed = [int]$script:stats.completed failed = [int]$script:stats.failed skipped = [int]$script:stats.skipped done = $done percent = $percent } } function Stop-AllWork { $stopped = 0 foreach ($job in @($script:activeJobs)) { try { if ($job.Process -and -not $job.Process.HasExited) { Stop-Process -Id $job.Process.Id -Force -ErrorAction Stop $stopped++ } } catch {} [void]$script:activeIds.Remove($job.TrackId) } $dropped = $script:queue.Count $script:queue.Clear() $script:queueIds.Clear() $script:activeJobs.Clear() Show-Dashboard -Force return [pscustomobject]@{ stopped = $stopped dropped = $dropped } } function Show-Dashboard { param([switch]$Force) $now = Get-Date if (-not $Force -and ($now - $script:lastDashboardAt).TotalSeconds -lt 2) { return } $script:lastDashboardAt = $now $snapshot = Get-QueueSnapshot Clear-Host Write-Host "SUNO Companion" -ForegroundColor Cyan Write-Host "Listening: http://127.0.0.1:$Port" Write-Host "Library: $libraryRoot" Write-Host "Logs: $logs" Write-Host "" Write-Host ("Workers: {0}/{1} Queue: {2} Done: {3}/{4} Failed: {5} Progress: {6}%" -f $snapshot.activeCount, $snapshot.maxWorkers, $snapshot.queued, $snapshot.done, $snapshot.total, $snapshot.failed, $snapshot.percent) Write-Host "" if ($snapshot.activeJobs.Count) { Write-Host "Active jobs:" -ForegroundColor Yellow foreach ($job in $snapshot.activeJobs) { $elapsed = [TimeSpan]::FromSeconds($job.elapsedSeconds) Write-Host ("- {0} [{1:mm\:ss}] pid={2}" -f $job.title, $elapsed, $job.pid) if ($job.log) { Write-Host " $($job.log)" -ForegroundColor DarkGray } } } else { Write-Host "Active jobs: none" -ForegroundColor DarkGray } if ($snapshot.next.Count) { Write-Host "" Write-Host "Next queue:" -ForegroundColor Yellow foreach ($item in $snapshot.next) { Write-Host "- $($item.title)" } } Write-Host "" Write-Host "Close this window to stop the companion." } function Update-Queue { for ($index = $script:activeJobs.Count - 1; $index -ge 0; $index--) { $job = $script:activeJobs[$index] if (-not $job.Process.HasExited) { Publish-JobProgress $job continue } Publish-JobProgress $job -Force if ($job.Process.ExitCode -eq 0) { $script:stats.completed++ } else { $script:stats.failed++ } [void]$script:activeIds.Remove($job.TrackId) $script:activeJobs.RemoveAt($index) Write-Host "Finished job $($job.JobId) exit=$($job.Process.ExitCode): $($job.Title)" } while ($script:activeJobs.Count -lt $MaxWorkers -and $script:queue.Count -gt 0) { $item = $script:queue.Dequeue() [void]$script:queueIds.Remove($item.Id) $manifest = [pscustomobject]@{ pageUrl = $item.PageUrl capturedAt = (Get-Date).ToString("o") count = 1 tracks = @($item.Track) } $manifestPath = Save-Manifest $manifest $job = Start-SunoProcess $manifestPath $item.Id $active = [pscustomobject]@{ JobId = $job.jobId Process = $job.process TrackId = $item.Id Title = $item.Title Log = $job.log ErrorLog = $job.errorLog StartedAt = Get-Date } [void]$script:activeJobs.Add($active) [void]$script:activeIds.Add($item.Id) Write-Host "Started queued job $($job.jobId): $($item.Title) [$($item.Id)]" } Show-Dashboard } function Add-TracksToQueue { param($Tracks, [string]$PageUrl) $sync = Get-MissingTracks $Tracks $added = @() $skipped = 0 foreach ($track in @($sync.missing)) { $id = "$($track.id)".Trim() if (-not $id) { $skipped++; continue } if ($queueIds.Contains($id)) { $skipped++; continue } if ($activeIds.Contains($id)) { $skipped++; continue } $title = if ($track.title) { "$($track.title)" } else { $id } $queue.Enqueue([pscustomobject]@{ Id = $id Title = $title Track = $track PageUrl = $PageUrl }) [void]$queueIds.Add($id) $added += $track } $script:stats.total += @($added).Count $script:stats.skipped += $skipped Update-Queue $snapshot = Get-QueueSnapshot return [pscustomobject]@{ added = @($added).Count skipped = $skipped queued = $snapshot.queued active = $snapshot.active activeCount = $snapshot.activeCount maxWorkers = $snapshot.maxWorkers completed = $snapshot.completed failed = $snapshot.failed total = $snapshot.total percent = $snapshot.percent } } function Save-UploadedTrack { param($Payload) if (-not $Payload.track) { throw "track is required" } $audioBase64 = if ($Payload.audioBase64) { "$($Payload.audioBase64)" } else { "$($Payload.wavBase64)" } if (-not $audioBase64) { throw "audioBase64 is required" } $track = $Payload.track $id = "$($track.id)".Trim() if (-not $id) { $id = Get-Date -Format "yyyyMMdd-HHmmssfff" } $ext = "$($Payload.audioExt)".Trim().TrimStart(".").ToLowerInvariant() if (-not $ext) { $ext = "wav" } if ($ext -notmatch '^[a-z0-9]{2,5}$') { $ext = "bin" } $audioPath = Join-Path $inbox ("suno-upload-{0}.{1}" -f $id, $ext) $bytes = [Convert]::FromBase64String($audioBase64) [IO.File]::WriteAllBytes($audioPath, $bytes) $track | Add-Member -NotePropertyName "localAudioPath" -NotePropertyValue $audioPath -Force $track | Add-Member -NotePropertyName "localAudioExt" -NotePropertyValue $ext -Force return Add-TracksToQueue @($track) "$($Payload.pageUrl)" } 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 "*.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)) { throw "processor not found: $processor" } $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), $Port) $listener.Start() $listener.Server.ReceiveTimeout = 1000 Write-Host "SUNO companion listening on http://127.0.0.1:$Port" Write-Host "OutputRoot: $OutputRoot" Write-Host "Library: $libraryRoot" Write-Host "Logs: $logs" Write-Host "Workers: $MaxWorkers" Write-Host "Press Ctrl+C to stop." Show-Dashboard -Force try { while ($true) { Update-Queue if (-not $listener.Pending()) { Start-Sleep -Milliseconds 500 continue } $client = $listener.AcceptTcpClient() try { $stream = $client.GetStream() $request = Read-HttpRequest $stream if ($request.Method -eq "OPTIONS") { Write-Response $stream 204 "No Content" "" continue } if ($request.Method -eq "GET" -and $request.Path -eq "/health") { Update-Queue $snapshot = Get-QueueSnapshot $payload = [ordered]@{ ok = $true outputRoot = $OutputRoot libraryRoot = $libraryRoot artist = $ArtistName queued = $snapshot.queued active = $snapshot.active activeCount = $snapshot.activeCount activeJobs = $snapshot.activeJobs next = $snapshot.next maxWorkers = $snapshot.maxWorkers total = $snapshot.total completed = $snapshot.completed failed = $snapshot.failed skipped = $snapshot.skipped done = $snapshot.done percent = $snapshot.percent } Write-Response $stream 200 "OK" ($payload | ConvertTo-Json -Depth 10 -Compress) continue } if ($request.Method -eq "POST" -and $request.Path -eq "/stop") { $result = Stop-AllWork Write-Host "Stopped work: active=$($result.stopped), queued=$($result.dropped)" $snapshot = Get-QueueSnapshot Write-Response $stream 200 "OK" (@{ ok = $true stopped = $result.stopped dropped = $result.dropped queued = $snapshot.queued activeCount = $snapshot.activeCount total = $snapshot.total completed = $snapshot.completed failed = $snapshot.failed percent = $snapshot.percent } | 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 } if ($request.Method -eq "POST" -and $request.Path -eq "/process") { $manifest = $request.Body | ConvertFrom-Json if (-not $manifest.tracks -or @($manifest.tracks).Count -eq 0) { Write-Response $stream 400 "Bad Request" (@{ ok = $false; error = "tracks is empty" } | ConvertTo-Json -Compress) continue } $result = Add-TracksToQueue @($manifest.tracks) "$($manifest.pageUrl)" if ($result.added -eq 0) { Write-Host "Process skipped: library already synced" Write-Response $stream 200 "OK" (@{ ok = $true skipped = $true message = "library already synced or already queued" queued = $result.queued active = $result.active activeCount = $result.activeCount maxWorkers = $result.maxWorkers completed = $result.completed failed = $result.failed total = $result.total percent = $result.percent } | ConvertTo-Json -Compress) continue } Write-Host "Queued process request: added=$($result.added), queued=$($result.queued), active=$($result.activeCount)" Write-Response $stream 200 "OK" (@{ ok = $true added = $result.added skipped = $result.skipped queued = $result.queued active = $result.active activeCount = $result.activeCount maxWorkers = $result.maxWorkers completed = $result.completed failed = $result.failed total = $result.total percent = $result.percent } | ConvertTo-Json -Compress) continue } if ($request.Method -eq "POST" -and $request.Path -eq "/enqueue") { $manifest = $request.Body | ConvertFrom-Json if (-not $manifest.tracks -or @($manifest.tracks).Count -eq 0) { Write-Response $stream 400 "Bad Request" (@{ ok = $false; error = "tracks is empty" } | ConvertTo-Json -Compress) continue } $result = Add-TracksToQueue @($manifest.tracks) "$($manifest.pageUrl)" Write-Host "Queue update: added=$($result.added), queued=$($result.queued), active=$($result.active)" Write-Response $stream 200 "OK" (@{ ok = $true added = $result.added skipped = $result.skipped queued = $result.queued active = $result.active activeCount = $result.activeCount maxWorkers = $result.maxWorkers completed = $result.completed failed = $result.failed total = $result.total percent = $result.percent } | ConvertTo-Json -Compress) continue } if ($request.Method -eq "POST" -and $request.Path -eq "/upload-track") { $payload = $request.Body | ConvertFrom-Json $result = Save-UploadedTrack $payload Write-Host "Upload queue update: added=$($result.added), queued=$($result.queued), active=$($result.active)" Write-Response $stream 200 "OK" (@{ ok = $true added = $result.added skipped = $result.skipped queued = $result.queued active = $result.active activeCount = $result.activeCount maxWorkers = $result.maxWorkers completed = $result.completed failed = $result.failed total = $result.total percent = $result.percent } | ConvertTo-Json -Compress) continue } Write-Response $stream 404 "Not Found" (@{ ok = $false; error = "not found" } | ConvertTo-Json -Compress) } catch { try { Write-Response $stream 500 "Internal Server Error" (@{ ok = $false; error = "$($_.Exception.Message)" } | ConvertTo-Json -Compress) } catch {} Write-Warning $_.Exception.Message } finally { $client.Close() } } } finally { $listener.Stop() }