792 lines
28 KiB
PowerShell
792 lines
28 KiB
PowerShell
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()
|
|
$pendingDownloads = [System.Collections.ArrayList]::new()
|
|
$claimedDownloadPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
$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
|
|
pendingDownloads = $script:pendingDownloads.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 Format-ProgressBar {
|
|
param(
|
|
[int]$Done,
|
|
[int]$Total,
|
|
[int]$Width = 28
|
|
)
|
|
|
|
if ($Total -le 0) {
|
|
return "[" + ("-" * $Width) + "]"
|
|
}
|
|
$ratio = [Math]::Max(0, [Math]::Min(1, $Done / $Total))
|
|
$filled = [int][Math]::Round($ratio * $Width)
|
|
return "[" + ("#" * $filled) + ("-" * ($Width - $filled)) + "]"
|
|
}
|
|
|
|
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} Waiting downloads: {3} Done: {4}/{5} Failed: {6} Progress: {7}%" -f $snapshot.activeCount, $snapshot.maxWorkers, $snapshot.queued, $snapshot.pendingDownloads, $snapshot.done, $snapshot.total, $snapshot.failed, $snapshot.percent)
|
|
Write-Host ("{0} {1}/{2}" -f (Format-ProgressBar $snapshot.done $snapshot.total), $snapshot.done, $snapshot.total) -ForegroundColor Cyan
|
|
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 + $skipped)
|
|
$script:stats.completed += $skipped
|
|
$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-DownloadsRoot {
|
|
$path = Join-Path $env:USERPROFILE "Downloads"
|
|
if (Test-Path -LiteralPath $path) { return $path }
|
|
return ""
|
|
}
|
|
|
|
function Test-StableDownloadFile {
|
|
param([System.IO.FileInfo]$File, [string]$Ext)
|
|
|
|
if (-not $File -or -not $File.Exists) { return $false }
|
|
if ($File.Name -like "*.crdownload" -or $File.Name -like "*.tmp") { return $false }
|
|
if ($File.Extension.TrimStart(".").ToLowerInvariant() -ne $Ext) { return $false }
|
|
if ($File.Length -lt 131072) { return $false }
|
|
return ((Get-Date) - $File.LastWriteTime).TotalSeconds -ge 2
|
|
}
|
|
|
|
function Find-ExpectedDownloadFile {
|
|
param($Expected)
|
|
|
|
$downloads = Get-DownloadsRoot
|
|
if (-not $downloads) { return $null }
|
|
$ext = "$($Expected.Format)".TrimStart(".").ToLowerInvariant()
|
|
$registeredAt = [DateTime]$Expected.RegisteredAt
|
|
$candidates = Get-ChildItem -LiteralPath $downloads -File -Filter "*.$ext" -ErrorAction SilentlyContinue |
|
|
Where-Object {
|
|
-not $script:claimedDownloadPaths.Contains($_.FullName) -and
|
|
$_.CreationTime -ge $registeredAt.AddSeconds(-10) -and
|
|
(Test-StableDownloadFile $_ $ext)
|
|
} |
|
|
Sort-Object CreationTime
|
|
|
|
return @($candidates)[0]
|
|
}
|
|
|
|
function Register-ExpectedDownloads {
|
|
param($Payload)
|
|
|
|
$format = "$($Payload.format)".TrimStart(".").ToLowerInvariant()
|
|
if ($format -notin @("mp3", "wav")) { throw "format must be mp3 or wav" }
|
|
$tracks = @($Payload.tracks)
|
|
if (-not $tracks.Count) { throw "tracks is empty" }
|
|
|
|
$missing = Get-MissingTracks $tracks
|
|
$added = 0
|
|
foreach ($track in @($missing.missing)) {
|
|
$id = "$($track.id)".Trim()
|
|
if (-not $id) { continue }
|
|
$alreadyPending = @($script:pendingDownloads | Where-Object { $_.Id -eq $id -and $_.Format -eq $format }).Count -gt 0
|
|
if ($alreadyPending -or $script:queueIds.Contains($id) -or $script:activeIds.Contains($id)) { continue }
|
|
[void]$script:pendingDownloads.Add([pscustomobject]@{
|
|
Id = $id
|
|
Title = "$($track.title)"
|
|
Track = $track
|
|
Format = $format
|
|
PageUrl = "$($Payload.pageUrl)"
|
|
RegisteredAt = Get-Date
|
|
})
|
|
$added++
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
added = $added
|
|
pending = $script:pendingDownloads.Count
|
|
existingRequested = @($missing.existingRequested).Count
|
|
missingRequested = @($missing.missing).Count
|
|
invalidRequested = $missing.invalid
|
|
}
|
|
}
|
|
|
|
function Update-ExpectedDownloads {
|
|
if (-not $script:pendingDownloads.Count) { return }
|
|
|
|
for ($index = $script:pendingDownloads.Count - 1; $index -ge 0; $index--) {
|
|
$expected = $script:pendingDownloads[$index]
|
|
$file = Find-ExpectedDownloadFile $expected
|
|
if (-not $file) { continue }
|
|
|
|
[void]$script:claimedDownloadPaths.Add($file.FullName)
|
|
$track = $expected.Track
|
|
$track | Add-Member -NotePropertyName "localAudioPath" -NotePropertyValue $file.FullName -Force
|
|
$track | Add-Member -NotePropertyName "localAudioExt" -NotePropertyValue $expected.Format -Force
|
|
$result = Add-TracksToQueue @($track) "$($expected.PageUrl)"
|
|
$script:pendingDownloads.RemoveAt($index)
|
|
Write-Host "Picked downloaded $($expected.Format): $($expected.Title) <- $($file.Name) queued=$($result.queued)"
|
|
}
|
|
}
|
|
|
|
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 = @()
|
|
$existingRequested = @()
|
|
$invalid = 0
|
|
foreach ($track in @($Tracks)) {
|
|
$id = "$($track.id)".Trim()
|
|
if (-not $id) { $invalid++; continue }
|
|
if (-not $existingIds.Contains($id)) {
|
|
$missing += $track
|
|
} else {
|
|
$existingRequested += $track
|
|
}
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
existingIds = @($existingIds)
|
|
existingRequested = $existingRequested
|
|
missing = $missing
|
|
invalid = $invalid
|
|
}
|
|
}
|
|
|
|
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-ExpectedDownloads
|
|
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
|
|
pendingDownloads = $snapshot.pendingDownloads
|
|
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
|
|
requested = @($manifest.tracks).Count
|
|
received = @($manifest.tracks).Count
|
|
libraryExistingTotal = @($result.existingIds).Count
|
|
existingRequested = @($result.existingRequested).Count
|
|
missingRequested = @($result.missing).Count
|
|
invalidRequested = $result.invalid
|
|
existing = @($result.existingRequested).Count
|
|
missing = @($result.missing).Count
|
|
tracks = @($result.missing)
|
|
} | ConvertTo-Json -Depth 50 -Compress)
|
|
continue
|
|
}
|
|
|
|
if ($request.Method -eq "POST" -and $request.Path -eq "/expect-downloads") {
|
|
$payload = $request.Body | ConvertFrom-Json
|
|
$result = Register-ExpectedDownloads $payload
|
|
Write-Host "Waiting for browser downloads: added=$($result.added), pending=$($result.pending), format=$($payload.format)"
|
|
Write-Response $stream 200 "OK" (@{
|
|
ok = $true
|
|
added = $result.added
|
|
pendingDownloads = $result.pending
|
|
existingRequested = $result.existingRequested
|
|
missingRequested = $result.missingRequested
|
|
invalidRequested = $result.invalidRequested
|
|
downloadsRoot = Get-DownloadsRoot
|
|
} | ConvertTo-Json -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()
|
|
}
|