openvisengine/tool-results/read_1793257458971_e7e497778ba8.txt
2026-10-29 07:19:50 +00:00

778 lines
No EOL
36 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof setTimeout>
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<boolean> {
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<boolean>((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<string, ResolveCacheEntry>
298→ __avaYtdlpSemaphore?: YtdlpSemaphore
299→}
300→
301→function resolveCache(): Map<string, ResolveCacheEntry> {
302→ const g = globalThis as AvaResolveGlobal
303→ g.__avaResolveCache ??= new Map<string, ResolveCacheEntry>()
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 <char>
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<CookiePayload | null> {
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<YtDlpResult> {
463→ return new Promise((resolve) => {
464→ let stdout = ''
465→ let stderr = ''
466→ let timedOut = false
467→ let settled = false
468→ let timer: ReturnType<typeof setTimeout> | 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<typeof spawn>
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-<service>-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→