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