param( [int]$Port = 17873, [string]$OutputRoot = "\\VaultOfData\AKAMedia\음악\처리됨", [string]$ArtistName = "SUNO", [string]$FfmpegPath = "" ) $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() $activeJob = $null $lastProgressAt = [DateTime]::MinValue $lastProgressText = "" 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 = New-Object System.Collections.Generic.List[byte] $headerEnd = -1 while ($headerEnd -lt 0) { $read = $Stream.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } for ($i = 0; $i -lt $read; $i++) { $received.Add($buffer[$i]) } $text = [Text.Encoding]::ASCII.GetString($received.ToArray()) $headerEnd = $text.IndexOf("`r`n`r`n", [StringComparison]::Ordinal) if ($received.Count -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 $bodyBytes = New-Object System.Collections.Generic.List[byte] for ($i = $bodyStart; $i -lt $allBytes.Length; $i++) { $bodyBytes.Add($allBytes[$i]) } while ($bodyBytes.Count -lt $contentLength) { $read = $Stream.Read($buffer, 0, [Math]::Min($buffer.Length, $contentLength - $bodyBytes.Count)) if ($read -le 0) { break } for ($i = 0; $i -lt $read; $i++) { $bodyBytes.Add($buffer[$i]) } } return [pscustomobject]@{ Method = $requestLine[0] Path = $requestLine[1] Headers = $headers Body = [Text.Encoding]::UTF8.GetString($bodyBytes.ToArray()) } } function Start-SunoProcess { param([string]$ManifestPath) $jobId = Get-Date -Format "yyyyMMdd-HHmmss" $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 Update-Queue { if ($script:activeJob -and -not $script:activeJob.Process.HasExited) { Publish-ActiveJobProgress return } if ($script:activeJob -and $script:activeJob.Process.HasExited) { Publish-ActiveJobProgress -Force Write-Host "Finished job $($script:activeJob.JobId) exit=$($script:activeJob.Process.ExitCode)" $script:activeJob = $null } while (-not $script:activeJob -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 $script:activeJob = [pscustomobject]@{ JobId = $job.jobId Process = $job.process TrackId = $item.Id Title = $item.Title Log = $job.log ErrorLog = $job.errorLog } Write-Host "Started queued job $($job.jobId): $($item.Title) [$($item.Id)]" } } 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 Publish-ActiveJobProgress { param([switch]$Force) if (-not $script:activeJob) { return } $now = Get-Date if (-not $Force -and ($now - $script:lastProgressAt).TotalSeconds -lt 10) { return } $line = Get-LastLogLine @($script:activeJob.ErrorLog, $script:activeJob.Log) if (-not $line) { return } if (-not $Force -and $line -eq $script:lastProgressText) { return } $script:lastProgressAt = $now $script:lastProgressText = $line Write-Host "[$($script:activeJob.JobId)] $($script:activeJob.Title): $line" } 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 ($activeJob -and $activeJob.TrackId -eq $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 } Update-Queue return [pscustomobject]@{ added = @($added).Count skipped = $skipped queued = $queue.Count active = if ($activeJob) { $activeJob.Title } else { "" } } } function Save-UploadedTrack { param($Payload) if (-not $Payload.track) { throw "track is required" } if (-not $Payload.wavBase64) { throw "wavBase64 is required" } $track = $Payload.track $id = "$($track.id)".Trim() if (-not $id) { $id = Get-Date -Format "yyyyMMdd-HHmmssfff" } $wavPath = Join-Path $inbox ("suno-upload-{0}.wav" -f $id) $bytes = [Convert]::FromBase64String("$($Payload.wavBase64)") [IO.File]::WriteAllBytes($wavPath, $bytes) $track | Add-Member -NotePropertyName "localWavPath" -NotePropertyValue $wavPath -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 "Press Ctrl+C to stop." 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 Write-Host "Health check OK: $(Get-Date -Format 'HH:mm:ss')" Write-Response $stream 200 "OK" (@{ ok = $true; outputRoot = $OutputRoot; libraryRoot = $libraryRoot; artist = $ArtistName; queued = $queue.Count; active = if ($activeJob) { $activeJob.Title } else { "" } } | 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 } $sync = Get-MissingTracks $manifest.tracks $manifest.tracks = @($sync.missing) $manifest.count = @($manifest.tracks).Count if ($manifest.count -eq 0) { Write-Host "Process skipped: library already synced" Write-Response $stream 200 "OK" (@{ ok = $true skipped = $true message = "library already synced" } | ConvertTo-Json -Compress) continue } $manifestPath = Join-Path $inbox ("suno-library-{0}.manifest.json" -f (Get-Date -Format "yyyyMMdd-HHmmss")) $manifest | ConvertTo-Json -Depth 50 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 $job = Start-SunoProcess $manifestPath Write-Host "Started job $($job.jobId) for $(@($manifest.tracks).Count) tracks" Write-Response $stream 200 "OK" (@{ ok = $true jobId = $job.jobId pid = $job.pid log = $job.log errorLog = $job.errorLog } | 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 } | 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 } | 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() }