Files
suno-helper/suno-companion.ps1
T
2026-05-02 00:42:21 +09:00

410 lines
14 KiB
PowerShell

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 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
}
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()
}