Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions CHANGELOG_V2_CAPTCHA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# V2 — CAPTCHA Handling Rewrite

## Problem

The original CAPTCHA handling in `SunoApi.ts` used hardcoded selectors (`.custom-textarea`) that broke when Suno updated their UI. This caused:

1. `TimeoutError: Timeout 30000ms exceeded` waiting for `.custom-textarea`
2. Massive log spam from polling loops (`"Sleeping for 0.25 seconds"` hundreds of times)
3. Unhandled promise rejections when the captcha-solver promise failed
4. No way to debug what the Suno page actually looked like

## Changes

### `src/lib/SunoApi.ts`

#### New Helper Methods

| Method | Purpose |
|---|---|
| `saveDebugSnapshot(page, label, requestLog?)` | Saves HTML, screenshot, request log, and frame list to `debug/` folder |
| `waitForCaptchaFrame(page, timeout?)` | Detects hCaptcha, reCAPTCHA, Cloudflare Turnstile, or Arkose/FunCaptcha iframes |
| `waitForAnyVisibleLocator(page, selectors, timeout?)` | Polls multiple CSS selectors, returns first visible. Uses raw `setTimeout(500)` to avoid log spam |

#### Rewritten `getCaptcha()`

The entire method was rewritten with numbered debug snapshots at every step:

| Step | Snapshot | Description |
|---|---|---|
| 1 | `01-page-loaded` | HTML + screenshot after navigating to `suno.com/create` |
| 2 | `02-interactive-elements.log` | All `<button>`, `<textarea>`, `<input>`, `[contenteditable]` elements with full attributes |
| 3 | `03-no-prompt-input` | Saved only if no prompt input was found |
| 4 | `04-no-create-button` | Saved only if no Create button was found |
| 5 | `05-after-create-click` | HTML + screenshot + request log after clicking Create |
| 6 | `06-after-second-click` | Same, after retry if first click didn't trigger CAPTCHA |
| 7 | `07-no-captcha-final` | Final state if no CAPTCHA appeared and generation didn't proceed |
| 8 | `08-unsupported-captcha` | If a non-hCaptcha provider was detected |

#### Key Improvements

- **Fallback selectors**: 10 selectors for prompt input, 8 for Create button
- **Popup dismissal**: Automatically closes modals/banners before interacting
- **Interactive element discovery**: Logs every interactive element on the page to help identify new selectors
- **Route interception before click**: Sets up `page.route('**/api/generate/v2/**')` before clicking Create so we never miss the generate call
- **Retry logic**: Clicks Create a second time if no CAPTCHA appears after the first click
- **Race condition handling**: Races between token interception and timeout, correctly handles generation proceeding without CAPTCHA
- **Error propagation**: Captcha solver errors are properly wired to the outer token promise via `rejectOuter()`

### `src/lib/utils.ts`

#### Updated `sleep()`

- Only logs calls ≥ 1 second to prevent polling log spam

#### Updated `waitForRequests()`

- Detects **6 URL patterns** across 4 CAPTCHA providers:
- `img*.hcaptcha.com` + `*.hcaptcha.com/captcha/`
- `www.google.com/recaptcha/` + `www.gstatic.com/recaptcha/`
- `challenges.cloudflare.com/`
- `*.arkoselabs.com/`
- Timeout increased from 60s → 120s
- Error message updated: "No CAPTCHA image/resource requests detected within 2 minutes"

## Debug Output

After running a request, check the `debug/` folder:

```
debug/
├── 01-page-loaded.html
├── 01-page-loaded.png
├── 01-page-loaded-frames.log
├── 01-page-loaded-requests.log
├── 02-interactive-elements.log ← Most valuable for selector debugging
├── 05-after-create-click.html
├── 05-after-create-click.png
├── 05-after-create-click-requests.log
├── 05-after-create-click-frames.log
└── ...
```

## How to Debug

1. Run `npm run dev`
2. Hit `/api/custom_generate` with a POST request
3. Check `debug/02-interactive-elements.log` — every interactive element on the Suno page with full attributes
4. Check screenshots to see what the page actually looks like
5. Check request logs to see what URLs were loaded
6. Adjust selectors in `promptSelectors` and `buttonSelectors` arrays if Suno changed their UI again
144 changes: 144 additions & 0 deletions CHANGELOG_V3_CONCURRENCY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# V3 — Async Concurrency Support

## Problem

The SunoApi class was not safe for concurrent requests. When multiple API calls hit the server simultaneously:

1. **Token refresh race**: Multiple calls to `keepAlive()` would all refresh the token at the same time, hammering the Clerk API
2. **Browser collision**: Multiple calls to `getCaptcha()` would each launch a separate browser, wasting resources
3. **No request tracking**: Logs from concurrent requests were interleaved with no way to tell them apart
4. **No concurrency limit**: Unlimited parallel generation requests could overwhelm the Suno API

## Changes

### `src/lib/utils.ts` — New Concurrency Primitives

#### `AsyncMutex`

A simple async mutual exclusion lock. Only one holder at a time; others queue up.

```typescript
const mutex = new AsyncMutex();

const release = await mutex.acquire();
try {
// Critical section — only one caller at a time
} finally {
release();
}
```

Properties:
- `isLocked: boolean` — Whether the mutex is currently held
- `queueLength: number` — How many callers are waiting

#### `AsyncSemaphore`

An async semaphore that allows up to N concurrent holders.

```typescript
const semaphore = new AsyncSemaphore(3); // max 3 concurrent

const release = await semaphore.acquire();
try {
// Up to 3 callers can be here simultaneously
} finally {
release();
}
```

Properties:
- `activeCount: number` — How many slots are currently in use
- `waitingCount: number` — How many callers are queued

### `src/lib/SunoApi.ts` — Concurrency Integration

#### New Instance Fields

| Field | Type | Purpose |
|---|---|---|
| `keepAliveMutex` | `AsyncMutex` | Serializes token refresh |
| `captchaMutex` | `AsyncMutex` | Serializes CAPTCHA browser sessions |
| `requestSemaphore` | `AsyncSemaphore` | Limits concurrent generation requests |
| `lastKeepAliveTime` | `number` | Timestamp of last successful token refresh |
| `requestCounter` | `number` | Auto-incrementing request ID for log tracing |
| `KEEPALIVE_COOLDOWN_MS` | `30000` | Skip refresh if token was refreshed within 30s |

#### Updated `keepAlive()`

- **Fast-path skip**: If token was refreshed within `KEEPALIVE_COOLDOWN_MS` (30s), returns immediately without acquiring the mutex
- **Mutex-protected refresh**: Only one caller actually refreshes the token
- **Double-check pattern**: After acquiring the mutex, re-checks the cooldown (another caller may have refreshed while waiting)

```
Request A ──► keepAlive() ──► acquires mutex ──► refreshes token ──► releases
Request B ──► keepAlive() ──► waits on mutex ──► checks cooldown ──► skips (recent) ──► releases
Request C ──► keepAlive() ──► cooldown check ──► skips immediately (fast-path)
```

#### Updated `getCaptcha()`

- **Mutex-serialized**: Only one browser session at a time
- **Re-check after lock**: After acquiring the mutex, re-checks `captchaRequired()` — a previous caller may have solved it
- **Queue visibility**: Logs how many requests are waiting when the mutex is contended

```
Request A ──► getCaptcha() ──► acquires mutex ──► launches browser ──► solves CAPTCHA ──► releases
Request B ──► getCaptcha() ──► waits on mutex ──► re-checks ──► CAPTCHA no longer needed ──► returns null
```

#### Updated `generateSongs()`

- **Semaphore-limited**: Controlled by `CONCURRENT_LIMIT` env var (default: 3)
- **Request IDs**: Each request gets `[req-N]` prefix in logs for traceability
- **Slot logging**: Logs active/waiting counts when acquiring a slot

```
[req-1] Acquired slot (active: 1, waiting: 0)
[req-2] Acquired slot (active: 2, waiting: 0)
[req-3] Acquired slot (active: 3, waiting: 0)
[req-4] ...waiting... (semaphore full)
[req-1] Released slot
[req-4] Acquired slot (active: 3, waiting: 0)
```

### `.env` — New Configuration

```env
# Max concurrent generation requests (default: 3)
CONCURRENT_LIMIT=3
```

## Concurrency Flow (5 simultaneous requests)

```
Time ──────────────────────────────────────────────────────►

req-1: ├─keepAlive(refresh)─┤─getCaptcha(null)─┤─generate──────────┤ done
req-2: ├─keepAlive(skip)────┤─getCaptcha(null)─┤─generate──────────┤ done
req-3: ├─keepAlive(skip)────┤─getCaptcha(null)─┤─generate──────────┤ done
req-4: ├─keepAlive(skip)────┤─getCaptcha(null)─┤──wait──┤─generate─┤ done
req-5: ├─keepAlive(skip)────┤─getCaptcha(null)─┤──wait──┤─generate─┤ done
semaphore limit (3)
```

## Response Format

Each request still returns **2 audio clips** (Suno platform behavior). So 5 concurrent requests = 10 total audio clips.

```json
// Single request response
[
{ "id": "abc-123", "title": "My Song", "status": "submitted" },
{ "id": "def-456", "title": "My Song", "status": "submitted" }
]
```

## How to Use

1. Set `CONCURRENT_LIMIT=3` in `.env` (or any number you want)
2. Run `npm run dev`
3. Fire multiple POST requests to `/api/custom_generate` simultaneously
4. Each request will be queued and processed in order, with at most `CONCURRENT_LIMIT` running at once
5. Logs will show `[req-N]` prefixes so you can trace each request
Loading