778 lines
No EOL
36 KiB
Text
778 lines
No EOL
36 KiB
Text
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→ |