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