1→ 1→import { spawn } from 'node:child_process' 2→ 2→import { existsSync } from 'node:fs' 3→ 3→import { mkdtemp, rm, writeFile } from 'node:fs/promises' 4→ 4→import { tmpdir } from 'node:os' 5→ 5→import { join } from 'node:path' 6→ 6→import { NextRequest, NextResponse } from 'next/server' 7→ 7→import { assertPublicHttpUrl } from '@/lib/server/ssrf' 8→ 8→import { createStreamEntry } from '@/lib/server/stream-store' 9→ 9→import { providerForUrl } from '@/lib/server/music-providers' 10→ 10→import { 11→ 11→ triggerYtdlpUpdate, 12→ 12→ ytdlpUpdateCooldownActive, 13→ 13→ ytdlpUpdateRunning, 14→ 14→} from '@/lib/server/ytdlp-selfupdate' 15→ 15→import { 16→ 16→ isServiceId, 17→ 17→ cookiePasteToNetscape, 18→ 18→ serviceDef, 19→ 19→ serviceForUrl, 20→ 20→ type ServiceId, 21→ 21→} from '@/lib/music-services' 22→ 22→import { houseCookieStatus, houseCookieText } from '@/lib/server/house-cookies' 23→ 23→ 24→ 24→export const runtime = 'nodejs' 25→ 25→export const dynamic = 'force-dynamic' 26→ 26→ 27→ 27→/** 28→ 28→ * POST /api/resolve 29→ 29→ * Body: { "url": string, "cookies"?: string, "cookieService"?: ServiceId } 30→ 30→ * — url: a YouTube / SoundCloud / Bandcamp / direct-file link, or a 31→ 31→ * playlist / set link. 32→ 32→ * — cookies + cookieService (optional): the visitor's own login cookie for 33→ 33→ * the matching music service (from the Settings tab), pasted as a raw 34→ 34→ * Cookie header, a cookies.txt export or a Cookie-Editor JSON export — 35→ 35→ * all normalized by the shared parser in lib/music-services.ts. The 36→ 36→ * cookie is used transiently — written to a 0600 temp file passed to 37→ 37→ * yt-dlp as --cookies, deleted immediately after, never logged and 38→ 38→ * never stored. 39→ 39→ * Playlists are returned as entry lists (flat, fast); the client plays 40→ 40→ * them as a lazy queue — one track resolved at a time, never in bulk. 41→ 41→ * Single tracks resolve to a same-origin proxy URL: 42→ 42→ * { ok, streamUrl, title, source, durationSec } 43→ 43→ * Playlists resolve to { ok, isPlaylist, title, source, entries[] }. 44→ 44→ * 45→ 45→ * v6.7 — anti-spam + music-service framework: 46→ 46→ * — CONCURRENCY GATE: at most 2 yt-dlp extractions run at once across ALL 47→ 47→ * requests (module-level FIFO semaphore, globalThis-backed). Surplus 48→ 48→ * requests wait in line up to 40 s, then get a 503 "busy" answer. The 49→ 49→ * server therefore NEVER bursts parallel extractions at YouTube even 50→ 50→ * with many visitors resolving at the same time — datacenter IPs that 51→ 51→ * hammer the extractor sites get temp-banned. Cache hits and direct-file 52→ 52→ * links bypass the gate entirely. 53→ 53→ * — COMING-SOON DISPATCH: URLs owned by a registered coming-soon service 54→ 54→ * (Spotify, Apple Music — DRM-locked, yt-dlp cannot stream them) are 55→ 55→ * intercepted right after the SSRF gate, BEFORE any cache/direct/yt-dlp 56→ 56→ * work, and answered with 57→ 57→ * { ok: false, code: 'coming-soon', service, error } (422) 58→ 58→ * where `error` carries the visitor-facing "later update" note. Live 59→ 59→ * service adapters dispatch at the same spot — see 60→ 60→ * src/lib/server/music-providers.ts for the extension recipe. 61→ 61→ * 62→ 62→ * v6.8 — self-healing extractor + honest failure codes: 63→ 63→ * — BOT WALLS answer { ok:false, code:'bot-wall' } (422) with the 64→ 64→ * "Google login required. Check the Settings tab to login, then try 65→ 65→ * again." message on YouTube (other services get the same guidance with 66→ 66→ * their name) — the client turns the code into an Open-Settings action. 67→ 67→ * — STALE EXTRACTOR signatures (YouTube rotated their mechanics: nsig 68→ 68→ * failures, "Unable to extract …", python tracebacks, no formats …) 69→ 69→ * trigger a BACKGROUND SELF-UPDATE of bin/yt-dlp (latest GitHub release, 70→ 70→ * verified + atomically swapped — see lib/server/ytdlp-selfupdate.ts) 71→ 71→ * and answer { ok:false, code:'ytdlp-updating' } (503). The client 72→ 72→ * shows "Updating in Background", keeps retrying, and the song picks 73→ 73→ * up seamlessly once the new binary is in. 74→ 74→ * — When an update already ran recently (cooldown) or shipped no new 75→ 75→ * version, stale failures answer 76→ 76→ * { ok:false, code:'backend-error' } (500) with "A back-end problem has 77→ 77→ * occurred. Please try again later." 78→ 78→ */ 79→ 79→ 80→ 80→const MAX_URL_LENGTH = 2048 81→ 81→const MAX_COOKIES_LENGTH = 65_536 82→ 82→const MAX_PLAYLIST_ENTRIES = 200 83→ 83→const EXTRACT_TIMEOUT_MS = 30_000 84→ 84→const RESOLVE_CACHE_TTL_MS = 10 * 60_000 85→ 85→const MAX_STDOUT_BYTES = 32 * 1024 * 1024 86→ 86→ 87→ 87→// ---- yt-dlp concurrency semaphore (v6.7 anti-spam) ------------------------- 88→ 88→// 89→ 89→// The user's hard requirement: never hammer YouTube (or any extractor site) 90→ 90→// with parallel/bulk extractions — datacenter IPs that burst requests get 91→ 91→// temp-banned. Every extraction goes through this semaphore: at most 92→ 92→// YTDLP_MAX_CONCURRENCY yt-dlp processes run at once across ALL requests; 93→ 93→// anything beyond that FIFO-queues for up to YTDLP_QUEUE_TIMEOUT_MS and then 94→ 94→// fails with a 503 "busy" answer. Kept on globalThis (like the resolve 95→ 95→// cache) so dev-mode module reloads never reset it mid-flight. 96→ 96→ 97→ 97→const YTDLP_MAX_CONCURRENCY = 2 98→ 98→const YTDLP_QUEUE_TIMEOUT_MS = 40_000 99→ 99→ 100→ 100→interface SemaphoreWaiter { 101→ 101→ /** Resolves the acquire() promise with true — a slot was granted. */ 102→ 102→ grant: () => void 103→ 103→ /** Abandonment timer — race-safe handoff, see acquire(). */ 104→ 104→ timer: ReturnType 105→ 105→} 106→ 106→ 107→ 107→class YtdlpSemaphore { 108→ 108→ private active = 0 109→ 109→ private waiters: SemaphoreWaiter[] = [] 110→ 110→ 111→ 111→ /** 112→ 112→ * Take an extraction slot. Resolves true immediately when a slot is free, 113→ 113→ * false when the FIFO wait exceeded YTDLP_QUEUE_TIMEOUT_MS. 114→ 114→ * 115→ 115→ * Race safety (a timed-out waiter must never double-release a slot it was 116→ 116→ * granted in the same tick): membership in `waiters` is the single source 117→ 117→ * of truth. The timeout callback only abandons the waiter while it is 118→ 118→ * STILL QUEUED (never consumed a slot → no release owed). If release() 119→ 119→ * already shifted the waiter out — the slot was legitimately transferred 120→ 120→ * to it — the callback finds nothing and does nothing; the caller still 121→ 121→ * holds exactly one slot and releases exactly once. 122→ 122→ */ 123→ 123→ acquire(): Promise { 124→ 124→ // Fast path — but never jump the queue: a non-empty waiter list means 125→ 125→ // every free slot belongs to the head of that line. 126→ 126→ if (this.waiters.length === 0 && this.active < YTDLP_MAX_CONCURRENCY) { 127→ 127→ this.active++ 128→ 128→ return Promise.resolve(true) 129→ 129→ } 130→ 130→ return new Promise((resolve) => { 131→ 131→ const waiter: SemaphoreWaiter = { 132→ 132→ grant: () => resolve(true), 133→ 133→ timer: setTimeout(() => { 134→ 134→ const idx = this.waiters.indexOf(waiter) 135→ 135→ if (idx === -1) return // slot granted in the same tick — keep it 136→ 136→ this.waiters.splice(idx, 1) 137→ 137→ resolve(false) 138→ 138→ }, YTDLP_QUEUE_TIMEOUT_MS), 139→ 139→ } 140→ 140→ this.waiters.push(waiter) 141→ 141→ }) 142→ 142→ } 143→ 143→ 144→ 144→ /** Return a slot: hand it straight to the head of the queue (the active 145→ 145→ * count does not change on a handoff), otherwise mark the slot free. */ 146→ 146→ release(): void { 147→ 147→ const next = this.waiters.shift() 148→ 148→ if (next) { 149→ 149→ clearTimeout(next.timer) 150→ 150→ next.grant() 151→ 151→ return 152→ 152→ } 153→ 153→ if (this.active > 0) this.active-- 154→ 154→ } 155→ 155→} 156→ 156→ 157→ 157→function ytdlpSemaphore(): YtdlpSemaphore { 158→ 158→ const g = globalThis as AvaResolveGlobal 159→ 159→ g.__avaYtdlpSemaphore ??= new YtdlpSemaphore() 160→ 160→ return g.__avaYtdlpSemaphore 161→ 161→} 162→ 162→ 163→ 163→const YT_DLP_ARGS = [ 164→ 164→ '-J', 165→ 165→ '--no-warnings', 166→ 166→ // playlists come back as a fast flat entry list instead of being fully 167→ 167→ // extracted (watch?v=X&list=Y still resolves the single video only) 168→ 168→ '--flat-playlist', 169→ 169→ '--no-playlist', 170→ 170→ '--no-progress', 171→ 171→ '--socket-timeout', 172→ 172→ '15', 173→ 173→ '-f', 174→ 174→ 'bestaudio[protocol^=http]/bestaudio/best', 175→ 175→] 176→ 176→ 177→ 177→const DIRECT_AUDIO_RE = /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac|mp4|webm)$/i 178→ 178→const ADAPTIVE_EXT_RE = /\.(m3u8|mpd)$/i 179→ 179→const ADAPTIVE_PROTO_RE = /m3u8|mpd|dash/i 180→ 180→const UNSUPPORTED_RE = /unsupported url/i 181→ 181→/** YouTube's datacenter-IP wall: "Sign in to confirm you're not a bot". */ 182→ 182→const BOT_WALL_RE = /sign in to confirm|not a bot|cookies for the authentication/i 183→ 183→ 184→ 184→/** Stale-extractor signatures — YouTube (or any site) changed their 185→ 185→ * mechanics and the installed yt-dlp can no longer parse the page: 186→ 186→ * nsig/signature rotation, missing player data, python-level crashes, 187→ 187→ * empty format lists. These trigger the background self-update. */ 188→ 188→const STALE_EXTRACTOR_RE = 189→ 189→ /nsig extraction failed|signature extraction failed|unable to extract (?:yt ?initial data|initial player response|player id)|unable to find (?:handler function|function name)|traceback \(most recent call last\)|attributeerror|unboundlocalerror|typeerror|keyerror|indexerror|nameerror|expected string or bytes-like object|object has no attribute|object is not subscriptable|no video formats|requested format is not available|player response is not valid/i 190→ 190→ 191→ 191→const BACKEND_ERROR_MESSAGE = 'A back-end problem has occurred. Please try again later.' 192→ 192→const UPDATING_MESSAGE = 193→ 193→ 'Updating in Background — OVE is updating its audio engine on the server and will retry this song automatically.' 194→ 194→ 195→ 195→/** Bot-wall answers carry the code 'bot-wall' so the client can offer an 196→ 196→ * "Open Settings" action. YouTube gets the exact Google-login wording. */ 197→ 197→function botWallMessage(target: URL, usedCookie: ServiceId | null, houseCookie: boolean): string { 198→ 198→ const isYoutube = /(^|\.)youtube\.com$/i.test(target.hostname) || /(^|\.)youtu\.be$/i.test(target.hostname) 199→ 199→ if (isYoutube) { 200→ 200→ if (usedCookie) { 201→ 201→ return 'Google login required. Check the Settings tab to login, then try again. (Your saved Google login may have expired — re-entering it in Settings usually fixes this.)' 202→ 202→ } 203→ 203→ if (houseCookie) { 204→ 204→ return 'Google login required. Check the Settings tab to login, then try again. (The site’s shared account hit a usage limit — try again in a little while, or log in with your own Google account in Settings.)' 205→ 205→ } 206→ 206→ return 'Google login required. Check the Settings tab to login, then try again.' 207→ 207→ } 208→ 208→ if (houseCookie) { 209→ 209→ return 'This service is asking the server to prove it is not a bot, and the site’s shared account is currently limited. Log in from the Settings tab so your own account is used, then try again.' 210→ 210→ } 211→ 211→ return 'This service is asking the server to prove it is not a bot. Log in from the Settings tab so your own account is used, then try again.' 212→ 212→} 213→ 213→ 214→ 214→/** Stale-extractor answer: kick off (or join) the background self-update and 215→ 215→ * tell the client to keep retrying — or, when an update already ran 216→ 216→ * recently, give the honest back-end-problem answer. */ 217→ 217→function staleExtractorResponse(): NextResponse { 218→ 218→ if (ytdlpUpdateRunning() || !ytdlpUpdateCooldownActive()) { 219→ 219→ if (!ytdlpUpdateRunning()) triggerYtdlpUpdate() 220→ 220→ return NextResponse.json( 221→ 221→ { ok: false, code: 'ytdlp-updating', error: UPDATING_MESSAGE }, 222→ 222→ { status: 503, headers: { 'Retry-After': '10' } }, 223→ 223→ ) 224→ 224→ } 225→ 225→ // An update ran within the cooldown (it either shipped a new binary that 226→ 226→ // STILL fails, or shipped nothing) — retrying or re-updating is pointless. 227→ 227→ return NextResponse.json( 228→ 228→ { ok: false, code: 'backend-error', error: BACKEND_ERROR_MESSAGE }, 229→ 229→ { status: 500 }, 230→ 230→ ) 231→ 231→} 232→ 232→ 233→ 233→interface ResolveSuccess { 234→ 234→ ok: true 235→ 235→ streamUrl: string 236→ 236→ title: string 237→ 237→ source: string 238→ 238→ durationSec: number | null 239→ 239→} 240→ 240→ 241→ 241→interface PlaylistEntry { 242→ 242→ url: string 243→ 243→ title: string 244→ 244→ durationSec: number | null 245→ 245→} 246→ 246→ 247→ 247→interface PlaylistSuccess { 248→ 248→ ok: true 249→ 249→ isPlaylist: true 250→ 250→ title: string 251→ 251→ source: string 252→ 252→ entries: PlaylistEntry[] 253→ 253→} 254→ 254→ 255→ 255→interface YtDlpFormat { 256→ 256→ url?: unknown 257→ 257→ protocol?: unknown 258→ 258→} 259→ 259→ 260→ 260→interface YtDlpEntry { 261→ 261→ url?: unknown 262→ 262→ id?: unknown 263→ 263→ title?: unknown 264→ 264→ duration?: unknown 265→ 265→} 266→ 266→ 267→ 267→interface YtDlpInfo { 268→ 268→ _type?: unknown 269→ 269→ url?: unknown 270→ 270→ protocol?: unknown 271→ 271→ title?: unknown 272→ 272→ duration?: unknown 273→ 273→ extractor?: unknown 274→ 274→ extractor_key?: unknown 275→ 275→ entries?: YtDlpEntry[] | null 276→ 276→ formats?: YtDlpFormat[] | null 277→ 277→ requested_formats?: YtDlpFormat[] | null 278→ 278→} 279→ 279→ 280→ 280→interface YtDlpResult { 281→ 281→ code: number | null 282→ 282→ signal: string | null 283→ 283→ stdout: string 284→ 284→ stderr: string 285→ 285→ timedOut: boolean 286→ 286→ spawnError: string | null 287→ 287→} 288→ 288→ 289→ 289→// ---- success cache (globalThis so dev-mode module reloads keep it) ---- 290→ 290→ 291→ 291→interface ResolveCacheEntry { 292→ 292→ payload: ResolveSuccess 293→ 293→ expires: number 294→ 294→} 295→ 295→ 296→ 296→interface AvaResolveGlobal { 297→ 297→ __avaResolveCache?: Map 298→ 298→ __avaYtdlpSemaphore?: YtdlpSemaphore 299→ 299→} 300→ 300→ 301→ 301→function resolveCache(): Map { 302→ 302→ const g = globalThis as AvaResolveGlobal 303→ 303→ g.__avaResolveCache ??= new Map() 304→ 304→ return g.__avaResolveCache 305→ 305→} 306→ 306→ 307→ 307→function cacheGet(key: string): ResolveSuccess | null { 308→ 308→ const map = resolveCache() 309→ 309→ const now = Date.now() 310→ 310→ for (const [k, v] of map) { 311→ 311→ if (v.expires <= now) map.delete(k) 312→ 312→ } 313→ 313→ const hit = map.get(key) 314→ 314→ return hit ? hit.payload : null 315→ 315→} 316→ 316→ 317→ 317→function cacheSet(key: string, payload: ResolveSuccess): void { 318→ 318→ resolveCache().set(key, { payload, expires: Date.now() + RESOLVE_CACHE_TTL_MS }) 319→ 319→} 320→ 320→ 321→ 321→// ---- helpers ---- 322→ 322→ 323→ 323→function fail(status: number, error: string): NextResponse { 324→ 324→ return NextResponse.json({ ok: false, error }, { status }) 325→ 325→} 326→ 326→ 327→ 327→/** yt-dlp binary: env override → project-local bin/ → PATH. */ 328→ 328→function ytdlpPath(): string { 329→ 329→ const fromEnv = process.env.AVA_YTDLP 330→ 330→ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv 331→ 331→ const local = join(process.cwd(), 'bin', 'yt-dlp') 332→ 332→ if (existsSync(local)) return local 333→ 333→ return 'yt-dlp' 334→ 334→} 335→ 335→ 336→ 336→/** Strip ANSI escapes + all other control chars (user-safe text). */ 337→ 337→function stripControlChars(input: string): string { 338→ 338→ let out = '' 339→ 339→ let i = 0 340→ 340→ while (i < input.length) { 341→ 341→ const code = input.charCodeAt(i) 342→ 342→ if (code === 0x1b) { 343→ 343→ const next = input[i + 1] ?? '' 344→ 344→ if (next === '[') { 345→ 345→ let j = i + 2 346→ 346→ while (j < input.length && '0123456789;?'.includes(input[j] ?? '')) j++ 347→ 347→ j++ // final byte of the CSI sequence 348→ 348→ i = j 349→ 349→ } else { 350→ 350→ i += next === '' ? 1 : 2 // lone ESC or ESC 351→ 351→ } 352→ 352→ continue 353→ 353→ } 354→ 354→ if (code < 0x20 || code === 0x7f) { 355→ 355→ i++ 356→ 356→ continue 357→ 357→ } 358→ 358→ out += input[i] 359→ 359→ i++ 360→ 360→ } 361→ 361→ return out 362→ 362→} 363→ 363→ 364→ 364→function cleanText(input: string, max: number): string { 365→ 365→ return stripControlChars(input).trim().slice(0, max) 366→ 366→} 367→ 367→ 368→ 368→/** Last path segment, decodeURIComponent'd; hostname as fallback. */ 369→ 369→function directTitle(url: URL): string { 370→ 370→ const segments = url.pathname.split('/').filter((s) => s.length > 0) 371→ 371→ const last = segments.length > 0 ? segments[segments.length - 1] : '' 372→ 372→ let decoded = last 373→ 373→ if (last) { 374→ 374→ try { 375→ 375→ decoded = decodeURIComponent(last) 376→ 376→ } catch { 377→ 377→ decoded = last 378→ 378→ } 379→ 379→ } 380→ 380→ const cleaned = cleanText(decoded, 140) 381→ 381→ return cleaned.length > 0 ? cleaned : url.hostname 382→ 382→} 383→ 383→ 384→ 384→/** data.url → requested_formats[0] → last http-protocol format. */ 385→ 385→function pickStream(data: YtDlpInfo): { url: string; protocol: string } | null { 386→ 386→ if (typeof data.url === 'string' && data.url) { 387→ 387→ return { url: data.url, protocol: typeof data.protocol === 'string' ? data.protocol : '' } 388→ 388→ } 389→ 389→ const requested = Array.isArray(data.requested_formats) ? data.requested_formats : [] 390→ 390→ const first = requested[0] 391→ 391→ if (first && typeof first.url === 'string' && first.url) { 392→ 392→ return { url: first.url, protocol: typeof first.protocol === 'string' ? first.protocol : '' } 393→ 393→ } 394→ 394→ const formats = Array.isArray(data.formats) ? data.formats : [] 395→ 395→ let last: { url: string; protocol: string } | null = null 396→ 396→ for (const f of formats) { 397→ 397→ const u = typeof f.url === 'string' ? f.url : '' 398→ 398→ const p = typeof f.protocol === 'string' ? f.protocol : '' 399→ 399→ if (u && p.startsWith('http')) last = { url: u, protocol: p } 400→ 400→ } 401→ 401→ return last 402→ 402→} 403→ 403→ 404→ 404→function ytdlpSource(data: YtDlpInfo, fallbackHost: string): string { 405→ 405→ const key = typeof data.extractor_key === 'string' ? data.extractor_key : '' 406→ 406→ const ex = typeof data.extractor === 'string' ? data.extractor : '' 407→ 407→ return (key || ex || fallbackHost).toLowerCase() 408→ 408→} 409→ 409→ 410→ 410→// ---- visitor cookie handling (transient, never persisted) -------------------- 411→ 411→ 412→ 412→interface CookiePayload { 413→ 413→ /** path of the 0600 temp cookies.txt for yt-dlp --cookies */ 414→ 414→ filePath: string 415→ 415→ /** temp dir to remove after the request */ 416→ 416→ dir: string 417→ 417→ /** "name=value; …" header — forwarded to the stream proxy ONLY when the 418→ 418→ * resolved upstream host belongs to the same service domain. */ 419→ 419→ header: string 420→ 420→ service: ServiceId 421→ 421→} 422→ 422→ 423→ 423→/** 424→ 424→ * Normalize pasted cookie data into a Netscape cookie file body + a Cookie 425→ 425→ * request header. Shared with the login dialog's live validation: 426→ 426→ * cookiePasteToNetscape() in lib/music-services.ts (v6.8.1) parses raw 427→ 427→ * "k=v; k2=v2" headers, Netscape cookies.txt exports AND Cookie-Editor JSON 428→ 428→ * exports — one parser on both sides so they can never drift apart. 429→ 429→ * Returns null when nothing parseable was pasted. 430→ 430→ */ 431→ 431→ 432→ 432→/** Write the normalized cookie text to a private temp file for yt-dlp. */ 433→ 433→async function stageCookies(text: string, service: ServiceId): Promise { 434→ 434→ const parts = cookiePasteToNetscape(text, service) 435→ 435→ if (!parts) return null 436→ 436→ const dir = await mkdtemp(join(tmpdir(), 'ove-cookies-')) 437→ 437→ const filePath = join(dir, 'cookies.txt') 438→ 438→ await writeFile(filePath, parts.netscape, { mode: 0o600 }) 439→ 439→ return { filePath, dir, header: parts.header, service } 440→ 440→} 441→ 441→ 442→ 442→/** True when the resolved stream host belongs to the login's service. */ 443→ 443→function headerAllowedForHost(header: string, service: ServiceId, host: string): boolean { 444→ 444→ if (!header) return false 445→ 445→ const root = serviceDef(service).cookieDomain.replace(/^\./, '') 446→ 446→ const h = host.toLowerCase() 447→ 447→ return h === root || h.endsWith(`.${root}`) 448→ 448→} 449→ 449→ 450→ 450→/** Run yt-dlp, killing it after EXTRACT_TIMEOUT_MS. 451→ 451→ * v6.9: the project bin/ dir is prepended to PATH so yt-dlp finds the 452→ 452→ * Deno runtime (bin/deno) — YouTube's signature/n-challenge solving for 453→ 453→ * cookie-authenticated extractions requires a JS runtime (EJS); without 454→ 454→ * it, authenticated requests return storyboards only ("Requested format 455→ 455→ * is not available"). */ 456→ 456→function ytdlpEnv(): NodeJS.ProcessEnv { 457→ 457→ const binDir = join(process.cwd(), 'bin') 458→ 458→ const path = process.env.PATH ?? '' 459→ 459→ return { ...process.env, PATH: path.split(':').includes(binDir) ? path : `${binDir}:${path}` } 460→ 460→} 461→ 461→ 462→ 462→function runYtDlp(url: string, extraArgs: string[]): Promise { 463→ 463→ return new Promise((resolve) => { 464→ 464→ let stdout = '' 465→ 465→ let stderr = '' 466→ 466→ let timedOut = false 467→ 467→ let settled = false 468→ 468→ let timer: ReturnType | undefined 469→ 469→ 470→ 470→ const done = (code: number | null, signal: string | null, spawnError: string | null): void => { 471→ 471→ if (settled) return 472→ 472→ settled = true 473→ 473→ if (timer !== undefined) clearTimeout(timer) 474→ 474→ resolve({ code, signal, stdout, stderr, timedOut, spawnError }) 475→ 475→ } 476→ 476→ 477→ 477→ let child: ReturnType 478→ 478→ try { 479→ 479→ child = spawn(ytdlpPath(), [...YT_DLP_ARGS, ...extraArgs, url], { 480→ 480→ stdio: ['ignore', 'pipe', 'pipe'], 481→ 481→ env: ytdlpEnv(), 482→ 482→ }) 483→ 483→ } catch (err) { 484→ 484→ done(null, null, err instanceof Error ? err.message : 'failed to start yt-dlp') 485→ 485→ return 486→ 486→ } 487→ 487→ 488→ 488→ timer = setTimeout(() => { 489→ 489→ timedOut = true 490→ 490→ try { 491→ 491→ child.kill('SIGKILL') 492→ 492→ } catch { 493→ 493→ // already gone — 'close' will still fire 494→ 494→ } 495→ 495→ }, EXTRACT_TIMEOUT_MS) 496→ 496→ 497→ 497→ child.stdout?.on('data', (chunk: Buffer) => { 498→ 498→ stdout += chunk.toString('utf8') 499→ 499→ if (stdout.length > MAX_STDOUT_BYTES) { 500→ 500→ try { 501→ 501→ child.kill('SIGKILL') 502→ 502→ } catch { 503→ 503→ // ignore 504→ 504→ } 505→ 505→ } 506→ 506→ }) 507→ 507→ child.stderr?.on('data', (chunk: Buffer) => { 508→ 508→ stderr += chunk.toString('utf8') 509→ 509→ }) 510→ 510→ child.on('error', (err: Error) => done(null, null, err.message)) 511→ 511→ child.on('close', (code: number | null, signal: string | null) => done(code, signal, null)) 512→ 512→ }) 513→ 513→} 514→ 514→ 515→ 515→/** Map flat playlist entries to {url,title,duration} (capped). */ 516→ 516→function playlistEntries(data: YtDlpInfo, isYoutube: boolean): PlaylistEntry[] { 517→ 517→ const entries = Array.isArray(data.entries) ? data.entries : [] 518→ 518→ const out: PlaylistEntry[] = [] 519→ 519→ for (const e of entries) { 520→ 520→ if (out.length >= MAX_PLAYLIST_ENTRIES) break 521→ 521→ let url = typeof e.url === 'string' ? e.url.trim() : '' 522→ 522→ if (url && !/^https?:\/\//i.test(url)) { 523→ 523→ // some extractors emit bare ids — only youtube ids can be rebuilt 524→ 524→ url = isYoutube && /^[\w-]{6,20}$/.test(url) ? `https://www.youtube.com/watch?v=${url}` : '' 525→ 525→ } 526→ 526→ if (!url && isYoutube && typeof e.id === 'string' && /^[\w-]{6,20}$/.test(e.id)) { 527→ 527→ url = `https://www.youtube.com/watch?v=${e.id}` 528→ 528→ } 529→ 529→ if (!url) continue 530→ 530→ const title = cleanText(typeof e.title === 'string' ? e.title : '', 140) || 'Untitled track' 531→ 531→ out.push({ url, title, durationSec: typeof e.duration === 'number' ? e.duration : null }) 532→ 532→ } 533→ 533→ return out 534→ 534→} 535→ 535→ 536→ 536→// ---- route ---- 537→ 537→ 538→ 538→/** GET /api/resolve — which services currently have the operator's shared 539→ 539→ * "house" account configured (presence booleans only, never contents). 540→ 540→ * The Settings tab + login dialog use this to tell visitors they can 541→ 541→ * play those services' links without logging in. */ 542→ 542→export async function GET() { 543→ 543→ return NextResponse.json( 544→ 544→ { ok: true, house: houseCookieStatus() }, 545→ 545→ { headers: { 'Cache-Control': 'no-store' } }, 546→ 546→ ) 547→ 547→} 548→ 548→ 549→ 549→export async function POST(request: NextRequest) { 550→ 550→ // 1. body 551→ 551→ let body: unknown 552→ 552→ try { 553→ 553→ body = await request.json() 554→ 554→ } catch { 555→ 555→ return fail(400, 'Expected { url } JSON body.') 556→ 556→ } 557→ 557→ if (body === null || typeof body !== 'object') { 558→ 558→ return fail(400, 'Expected { url } JSON body.') 559→ 559→ } 560→ 560→ const urlField = (body as { url?: unknown }).url 561→ 561→ if (typeof urlField !== 'string') { 562→ 562→ return fail(400, 'Expected { url } JSON body.') 563→ 563→ } 564→ 564→ const raw = urlField.trim() 565→ 565→ if (raw.length > MAX_URL_LENGTH) { 566→ 566→ return fail(400, 'That link is too long.') 567→ 567→ } 568→ 568→ 569→ 569→ // optional visitor login cookie (Settings tab → Music services) 570→ 570→ const cookiesField = (body as { cookies?: unknown }).cookies 571→ 571→ const cookieServiceField = (body as { cookieService?: unknown }).cookieService 572→ 572→ let cookieText: string | null = null 573→ 573→ let cookieService: ServiceId | null = null 574→ 574→ if (typeof cookiesField === 'string' && cookiesField.trim().length > 0) { 575→ 575→ if (cookiesField.length > MAX_COOKIES_LENGTH) { 576→ 576→ return fail(400, 'Cookie data is too large.') 577→ 577→ } 578→ 578→ if (!isServiceId(cookieServiceField)) { 579→ 579→ return fail(400, 'Missing music service for the provided login cookie.') 580→ 580→ } 581→ 581→ cookieText = cookiesField 582→ 582→ cookieService = cookieServiceField 583→ 583→ } 584→ 584→ 585→ 585→ // 2. SSRF gate on the input URL 586→ 586→ let target: URL 587→ 587→ try { 588→ 588→ target = await assertPublicHttpUrl(raw) 589→ 589→ } catch (err) { 590→ 590→ return fail(400, err instanceof Error ? err.message : 'That link is not allowed.') 591→ 591→ } 592→ 592→ 593→ 593→ // 2b. music-service provider dispatch — URLs owned by a registered 594→ 594→ // coming-soon service (Spotify, Apple Music: DRM-locked, yt-dlp can 595→ 595→ // never stream them) are answered here with the honest "later update" 596→ 596→ // note and NEVER reach the cache, yt-dlp or any extractor process. 597→ 597→ // Live service adapters dispatch at this same spot (v6.8+) — see 598→ 598→ // src/lib/server/music-providers.ts for the extension recipe. 599→ 599→ const provider = providerForUrl(target.href) 600→ 600→ if (provider.status === 'coming-soon') { 601→ 601→ return NextResponse.json( 602→ 602→ { ok: false, code: 'coming-soon', service: provider.id, error: provider.comingSoonNote }, 603→ 603→ { status: 422 }, 604→ 604→ ) 605→ 605→ } 606→ 606→ 607→ 607→ // 3. success cache — keyed by link AND which authentication is in play 608→ 608→ // (v6.9: house fallback gets its own key — a house-account resolve can 609→ 609→ // succeed where a true anonymous one bot-walls) 610→ 610→ const linkService = serviceForUrl(target.href) 611→ 611→ const houseText = linkService !== null && cookieService === null ? houseCookieText(linkService) : null 612→ 612→ const cacheKey = 613→ 613→ target.href + (cookieService ? '|auth' : houseText !== null ? '|house' : '|anon') 614→ 614→ const cached = cacheGet(cacheKey) 615→ 615→ if (cached) return NextResponse.json(cached) 616→ 616→ 617→ 617→ // 4. direct-audio bypass (never needs cookies) 618→ 618→ if (DIRECT_AUDIO_RE.test(target.pathname)) { 619→ 619→ const title = directTitle(target) 620→ 620→ const token = createStreamEntry(target.href, title, 'direct') 621→ 621→ const payload: ResolveSuccess = { 622→ 622→ ok: true, 623→ 623→ streamUrl: `/api/audio/stream?token=${token}`, 624→ 624→ title, 625→ 625→ source: 'direct', 626→ 626→ durationSec: null, 627→ 627→ } 628→ 628→ cacheSet(cacheKey, payload) 629→ 629→ return NextResponse.json(payload) 630→ 630→ } 631→ 631→ 632→ 632→ // 5. yt-dlp extraction — gated by the anti-spam semaphore (at most 2 633→ 633→ // concurrent extractions across ALL requests; surplus callers FIFO- 634→ 634→ // queue up to 40 s, then get a 503) — with a login cookie staged in a 635→ 635→ // private temp file that is removed right after (never logged, never 636→ 636→ // stored). v6.9 priority: the visitor's OWN login first; otherwise the 637→ 637→ // operator's shared house account (secrets/house--cookies.txt) 638→ 638→ // when one exists for the link's service; only then truly anonymous. 639→ 639→ let cookie: CookiePayload | null = null 640→ 640→ let cookieFromHouse = false 641→ 641→ if (cookieText !== null && cookieService !== null) { 642→ 642→ try { 643→ 643→ cookie = await stageCookies(cookieText, cookieService) 644→ 644→ } catch { 645→ 645→ return fail(500, 'Could not stage the login cookie on the server.') 646→ 646→ } 647→ 647→ if (!cookie) { 648→ 648→ return fail(400, 'Could not read that cookie data — paste the Cookie header value or a cookies.txt export.') 649→ 649→ } 650→ 650→ } else if (linkService !== null && houseText !== null) { 651→ 651→ try { 652→ 652→ cookie = await stageCookies(houseText, linkService) 653→ 653→ } catch { 654→ 654→ cookie = null 655→ 655→ } 656→ 656→ if (cookie) { 657→ 657→ cookieFromHouse = true 658→ 658→ console.log(`[house-cookie] staged for ${linkService}`) 659→ 659→ } 660→ 660→ } 661→ 661→ 662→ 662→ const semaphore = ytdlpSemaphore() 663→ 663→ let held = false 664→ 664→ try { 665→ 665→ held = await semaphore.acquire() 666→ 666→ if (!held) { 667→ 667→ return fail(503, 'The resolver is busy right now — try again in a moment.') 668→ 668→ } 669→ 669→ const result = await runYtDlp(target.href, cookie ? ['--cookies', cookie.filePath] : []) 670→ 670→ if (result.spawnError !== null) { 671→ 671→ return fail(500, 'Audio extractor unavailable (yt-dlp not found or failed to start).') 672→ 672→ } 673→ 673→ if (result.timedOut) { 674→ 674→ return fail(504, 'Extraction timed out — try again or use a direct audio link.') 675→ 675→ } 676→ 676→ if (result.code !== 0) { 677→ 677→ const cleanedStderr = stripControlChars(result.stderr).trim() 678→ 678→ const tail = cleanedStderr.slice(-300) 679→ 679→ if (UNSUPPORTED_RE.test(cleanedStderr)) { 680→ 680→ return fail(422, 'That site is not supported — try YouTube, SoundCloud, or a direct MP3 link.') 681→ 681→ } 682→ 682→ if (BOT_WALL_RE.test(cleanedStderr)) { 683→ 683→ // v6.8: code'd bot-wall answer — the client pairs it with an 684→ 684→ // "Open Settings" action (login cookies bypass the wall) 685→ 685→ return NextResponse.json( 686→ 686→ { ok: false, code: 'bot-wall', error: botWallMessage(target, cookieService, cookieFromHouse) }, 687→ 687→ { status: 422 }, 688→ 688→ ) 689→ 689→ } 690→ 690→ if (STALE_EXTRACTOR_RE.test(cleanedStderr)) { 691→ 691→ // v6.8 self-heal: trigger the background yt-dlp update and tell the 692→ 692→ // client to retry (or, post-cooldown, the honest failure answer) 693→ 693→ return staleExtractorResponse() 694→ 694→ } 695→ 695→ return fail(422, `Could not extract audio from that link: ${tail || 'extraction failed.'}`) 696→ 696→ } 697→ 697→ 698→ 698→ let data: YtDlpInfo 699→ 699→ try { 700→ 700→ data = JSON.parse(result.stdout) as YtDlpInfo 701→ 701→ } catch { 702→ 702→ return fail(422, 'Could not extract audio from that link: extractor returned unexpected output.') 703→ 703→ } 704→ 704→ 705→ 705→ const source = ytdlpSource(data, target.hostname) 706→ 706→ const isYoutube = /youtube/i.test(source) || /youtu\.be/i.test(target.hostname) 707→ 707→ 708→ 708→ // 5a. playlist / set → flat entry list; the client picks a track 709→ 709→ const entries = playlistEntries(data, isYoutube) 710→ 710→ if (entries.length > 0 && data._type === 'playlist') { 711→ 711→ const playlistTitle = 712→ 712→ cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Playlist' 713→ 713→ const payload: PlaylistSuccess = { 714→ 714→ ok: true, 715→ 715→ isPlaylist: true, 716→ 716→ title: playlistTitle, 717→ 717→ source, 718→ 718→ entries, 719→ 719→ } 720→ 720→ return NextResponse.json(payload) 721→ 721→ } 722→ 722→ 723→ 723→ // 5b. single track 724→ 724→ const pick = pickStream(data) 725→ 725→ if (!pick) { 726→ 726→ return fail(422, 'Could not extract audio from that link: no direct stream URL found.') 727→ 727→ } 728→ 728→ 729→ 729→ let upstream: URL 730→ 730→ try { 731→ 731→ upstream = new URL(pick.url) 732→ 732→ } catch { 733→ 733→ return fail(422, 'Could not extract audio from that link: no direct stream URL found.') 734→ 734→ } 735→ 735→ 736→ 736→ if (ADAPTIVE_EXT_RE.test(upstream.pathname) || ADAPTIVE_PROTO_RE.test(pick.protocol)) { 737→ 737→ return fail( 738→ 738→ 422, 739→ 739→ "This source only provides an adaptive (HLS/DASH) stream, which browsers can't play directly — try another link or a direct audio file.", 740→ 740→ ) 741→ 741→ } 742→ 742→ 743→ 743→ // 6. SSRF gate on the resolved stream URL (public CDNs pass) 744→ 744→ try { 745→ 745→ await assertPublicHttpUrl(upstream.href) 746→ 746→ } catch (err) { 747→ 747→ return fail(422, err instanceof Error ? err.message : 'That stream host is not allowed.') 748→ 748→ } 749→ 749→ 750→ 750→ // 7. build response — forward the Cookie header to the proxy ONLY when 751→ 751→ // the upstream host belongs to the same service (no cross-domain 752→ 752→ // cookie leakage to CDN hosts) 753→ 753→ const proxyHeader = 754→ 754→ cookie && headerAllowedForHost(cookie.header, cookie.service, upstream.hostname) 755→ 755→ ? cookie.header 756→ 756→ : undefined 757→ 757→ const title = cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Stream' 758→ 758→ const durationSec = typeof data.duration === 'number' ? data.duration : null 759→ 759→ const token = createStreamEntry(upstream.href, title, source, proxyHeader) 760→ 760→ const payload: ResolveSuccess = { 761→ 761→ ok: true, 762→ 762→ streamUrl: `/api/audio/stream?token=${token}`, 763→ 763→ title, 764→ 764→ source, 765→ 765→ durationSec, 766→ 766→ } 767→ 767→ cacheSet(cacheKey, payload) 768→ 768→ return NextResponse.json(payload) 769→ 769→ } finally { 770→ 770→ if (held) { 771→ 771→ semaphore.release() 772→ 772→ } 773→ 773→ if (cookie) { 774→ 774→ await rm(cookie.dir, { recursive: true, force: true }).catch(() => {}) 775→ 775→ } 776→ 776→ } 777→ 777→} 778→ 778→