Spaces:
Sleeping
Sleeping
| import express from 'express'; | |
| import cors from 'cors'; | |
| import ytdl from '@distube/ytdl-core'; | |
| import yts from 'youtube-yts'; | |
| import { HttpsProxyAgent } from 'https-proxy-agent'; | |
| import { exec } from 'child_process'; | |
| import { promisify } from 'util'; | |
| import fs from 'fs'; | |
| import path from 'path'; | |
| const execAsync = promisify(exec); | |
| const COOKIES_PATH = path.resolve('./cookies.txt'); | |
| // Dynamically write cookies from environment secret if present | |
| if (process.env.YT_COOKIES) { | |
| console.log('[Cookies] Dynamically writing cookies.txt from environment variable...'); | |
| try { | |
| fs.writeFileSync(COOKIES_PATH, process.env.YT_COOKIES, 'utf-8'); | |
| console.log('[Cookies] Successfully wrote cookies.txt to', COOKIES_PATH); | |
| } catch (err) { | |
| console.error('[Cookies] Failed to write cookies.txt:', err.message); | |
| } | |
| } else { | |
| console.log('[Cookies] YT_COOKIES environment secret not set, checking for local cookies.txt file...'); | |
| } | |
| const app = express(); | |
| app.use(cors()); | |
| app.use(express.json()); | |
| const PORT = process.env.PORT || 7860; | |
| // High-Availability Cobalt Pool | |
| const COBALT_INSTANCES = [ | |
| "https://cobalt.omega.wolfy.love", | |
| "https://apicobalt.mgytr.top", | |
| "https://api.qwkuns.me", | |
| "https://subito-c.meowing.de", | |
| "https://cobalt.alpha.wolfy.love" | |
| ]; | |
| // LAYER 4: Invidious Proxy Fallback (Ultimate 429/Age-Gate Bypass) | |
| const INVIDIOUS_INSTANCES = [ | |
| "https://invidious.f5.si", | |
| "https://invidious.tiekoetter.com", | |
| "https://invidious.nerdvpn.de", | |
| "https://yt.chocolatemoo53.com", | |
| "https://inv.thepixora.com", | |
| "https://inv.nadeko.net" | |
| ]; | |
| // LAYER 5: Piped API Fallback (Independent of YouTube's auth challenges) | |
| const PIPED_INSTANCES = [ | |
| "https://pipedapi.kavin.rocks", | |
| "https://pipedapi.adminforge.de", | |
| "https://piped-api.lunar.icu", | |
| "https://api.piped.projectsegfau.lt", | |
| "https://pipedapi.r4fo.com", | |
| "https://pipedapi.darkness.services" | |
| ]; | |
| // Innertube clients raced in parallel — same approach as the CF worker. | |
| // ANDROID_MUSIC is fastest; ANDROID_VR and TVHTML5 are independent fallbacks | |
| // that historically bypass the "Sign in" challenge that hits ANDROID_MUSIC. | |
| const INNERTUBE_CLIENTS = [ | |
| { name: "ANDROID_MUSIC", version: "6.19.52", userAgent: "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36" }, | |
| { name: "ANDROID_VR", version: "1.55.18", userAgent: "Mozilla/5.0 (Linux; Android 10; Quest 2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36" }, | |
| { name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", version: "2.0", userAgent: "Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15" } | |
| ]; | |
| // ============================================================ | |
| // LAYER 0 (PRIMARY): IZGIV-Botto 3rd-Party API Cascade | |
| // Ported from IZGIV-Botto/commands/song.js + play.js — these | |
| // downloader APIs (EliteProTech, Yupra, Okatsu, apis-keith) | |
| // are battle-tested in production and bypass every | |
| // signatureCipher / "Sign in" / 403 challenge. Raced in | |
| // parallel with 3-attempt retry (1s/2s/3s backoff) per | |
| // the IZGIV-Botto tryRequest pattern. | |
| // ============================================================ | |
| // Sanity check the upstream audio URL before returning it. | |
| // Some 3rd-party APIs (notably EliteProTech right now) return URLs to | |
| // well-formed MP4 headers but with empty/zeroed `mdat` boxes — the | |
| // file looks valid but plays silence. A HEAD probe of Content-Length | |
| // against a sane floor (100KB — a real 3-min song at 64kbps is ~1.4MB) | |
| // catches this without a full body download. | |
| const MIN_AUDIO_BYTES = 100_000; | |
| async function validateAudioSize(url, label) { | |
| try { | |
| const head = await fetchWithTimeout(url, { | |
| method: 'HEAD', | |
| headers: { 'User-Agent': 'Mozilla/5.0' }, | |
| timeout: 5000 | |
| }); | |
| if (!head.ok) throw new Error(`HEAD status ${head.status}`); | |
| const cl = parseInt(head.headers.get('content-length') || '0', 10); | |
| if (!cl || cl < MIN_AUDIO_BYTES) { | |
| throw new Error(`Too-small file: ${cl} bytes (min ${MIN_AUDIO_BYTES})`); | |
| } | |
| return true; | |
| } catch (err) { | |
| console.warn(`[Izgiv:${label}] size validation failed: ${err.message}`); | |
| throw err; | |
| } | |
| } | |
| async function getEliteProTechUrl(videoUrl) { | |
| const apiUrl = `https://eliteprotech-apis.zone.id/ytdown?url=${encodeURIComponent(videoUrl)}&format=mp3`; | |
| const res = await fetchWithTimeout(apiUrl, { | |
| headers: { 'User-Agent': 'Mozilla/5.0' }, | |
| timeout: 6000 | |
| }); | |
| if (!res.ok) throw new Error(`Status ${res.status}`); | |
| const data = await res.json(); | |
| // EliteProTech shape: { success: true, title, downloadURL } | { success: false, ... } | |
| if (!data?.success || !data?.downloadURL) throw new Error('No downloadURL in response'); | |
| await validateAudioSize(data.downloadURL, 'EliteProTech'); | |
| console.log(`[Izgiv:EliteProTech] ✅ resolved (title="${data.title}")`); | |
| return { url: data.downloadURL, title: data.title }; | |
| } | |
| async function getYupraUrl(videoUrl) { | |
| const apiUrl = `https://api.yupra.my.id/api/downloader/ytmp3?url=${encodeURIComponent(videoUrl)}`; | |
| const res = await fetchWithTimeout(apiUrl, { | |
| headers: { 'User-Agent': 'Mozilla/5.0' }, | |
| timeout: 6000 | |
| }); | |
| if (!res.ok) throw new Error(`Status ${res.status}`); | |
| const data = await res.json(); | |
| // Yupra shape: { success: true, data: { title, thumbnail, download_url, ... } } | |
| if (!data?.success || !data?.data?.download_url) throw new Error('No download_url in response'); | |
| await validateAudioSize(data.data.download_url, 'Yupra'); | |
| console.log(`[Izgiv:Yupra] ✅ resolved (title="${data.data.title}")`); | |
| return { url: data.data.download_url, title: data.data.title, thumbnail: data.data.thumbnail }; | |
| } | |
| async function getOkatsuUrl(videoUrl) { | |
| const apiUrl = `https://okatsu-rolezapiiz.vercel.app/downloader/ytmp3?url=${encodeURIComponent(videoUrl)}`; | |
| const res = await fetchWithTimeout(apiUrl, { | |
| headers: { 'User-Agent': 'Mozilla/5.0' }, | |
| timeout: 6000 | |
| }); | |
| if (res.status === 451) { | |
| console.warn(`[Izgiv:Okatsu] ⛔ 451 region/copyright block — skipping API`); | |
| return { _skipped: true }; | |
| } | |
| if (!res.ok) throw new Error(`Status ${res.status}`); | |
| const data = await res.json(); | |
| // Okatsu audio shape: { status, creator, title, format, thumb, duration, cached, dl } | |
| if (!data?.dl) throw new Error('No dl URL in response'); | |
| await validateAudioSize(data.dl, 'Okatsu'); | |
| console.log(`[Izgiv:Okatsu] ✅ resolved (title="${data.title}")`); | |
| return { url: data.dl, title: data.title, thumbnail: data.thumb }; | |
| } | |
| async function getApisKeithUrl(videoUrl) { | |
| const apiUrl = `https://apis-keith.vercel.app/download/dlmp3?url=${encodeURIComponent(videoUrl)}`; | |
| const res = await fetchWithTimeout(apiUrl, { | |
| headers: { 'User-Agent': 'Mozilla/5.0' }, | |
| timeout: 6000 | |
| }); | |
| if (!res.ok) throw new Error(`Status ${res.status}`); | |
| const data = await res.json(); | |
| // ApisKeith shape: { status: true, result: { title, mp3, image, duration, quality } } | |
| if (!data?.result?.mp3) throw new Error('No mp3 URL in response'); | |
| await validateAudioSize(data.result.mp3, 'ApisKeith'); | |
| console.log(`[Izgiv:ApisKeith] ✅ resolved (title="${data.result.title}")`); | |
| return { url: data.result.mp3, title: data.result.title, thumbnail: data.result.image }; | |
| } | |
| async function getA2ZConverterUrl(videoUrl) { | |
| const apiUrl = `https://www.a2zconverter.com/api/get-proxy-data?url=${encodeURIComponent(videoUrl)}`; | |
| const res = await fetchWithTimeout(apiUrl, { | |
| headers: { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', | |
| 'Referer': 'https://www.a2zconverter.com/youtube-video-downloader' | |
| }, | |
| timeout: 6000 | |
| }); | |
| if (!res.ok) throw new Error(`Status ${res.status}`); | |
| const data = await res.json(); | |
| if (!data?.status || !data?.data?.items?.audio) { | |
| throw new Error('Invalid or failed response from a2zconverter'); | |
| } | |
| const audios = data.data.items.audio; | |
| if (!audios || audios.length === 0) { | |
| throw new Error('No audio streams found in a2zconverter data'); | |
| } | |
| const bestAudio = audios[0]; | |
| if (!bestAudio.url) { | |
| throw new Error('No audio URL in a2zconverter item'); | |
| } | |
| await validateAudioSize(bestAudio.url, 'A2ZConverter'); | |
| console.log(`[Izgiv:A2ZConverter] ✅ resolved (title="${data.data.title}")`); | |
| return { url: bestAudio.url, title: data.data.title, thumbnail: data.data.cover }; | |
| } | |
| async function tryRequest(getter, label) { | |
| try { | |
| const data = await getter(); | |
| if (data && data._skipped) return { skipped: true }; | |
| if (data && data.url) return data; | |
| } catch (err) { | |
| console.warn(`[Izgiv:${label}] failed: ${err.message}`); | |
| } | |
| return null; | |
| } | |
| async function resolveIzgivBotto(videoId) { | |
| const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; | |
| console.log(`[Izgiv] Racing all 5 APIs in parallel for ${videoId}...`); | |
| try { | |
| const winner = await Promise.any([ | |
| tryRequest(() => getEliteProTechUrl(videoUrl), 'EliteProTech').then(res => { | |
| if (res && res.url) { | |
| res.strategy = 'izgiv:eliteprotech'; | |
| return res; | |
| } | |
| throw new Error('EliteProTech returned empty'); | |
| }), | |
| tryRequest(() => getYupraUrl(videoUrl), 'Yupra').then(res => { | |
| if (res && res.url) { | |
| res.strategy = 'izgiv:yupra'; | |
| return res; | |
| } | |
| throw new Error('Yupra returned empty'); | |
| }), | |
| tryRequest(() => getOkatsuUrl(videoUrl), 'Okatsu').then(res => { | |
| if (res && res.url) { | |
| res.strategy = 'izgiv:okatsu'; | |
| return res; | |
| } | |
| throw new Error('Okatsu returned empty'); | |
| }), | |
| tryRequest(() => getApisKeithUrl(videoUrl), 'ApisKeith').then(res => { | |
| if (res && res.url) { | |
| res.strategy = 'izgiv:apis-keith'; | |
| return res; | |
| } | |
| throw new Error('ApisKeith returned empty'); | |
| }), | |
| tryRequest(() => getA2ZConverterUrl(videoUrl), 'A2ZConverter').then(res => { | |
| if (res && res.url) { | |
| res.strategy = 'izgiv:a2zconverter'; | |
| return res; | |
| } | |
| throw new Error('A2ZConverter returned empty'); | |
| }) | |
| ]); | |
| console.log(`[Izgiv] ✅ Resolved ${videoId} via ${winner.strategy}`); | |
| return winner; | |
| } catch (err) { | |
| console.warn('[Izgiv] All IZGIV APIs failed — falling through to next Tier'); | |
| return null; | |
| } | |
| } | |
| // --- HIGH-PERFORMANCE OUTBOUND PROXY POOL ROTATION --- | |
| const PROXY_POOL = process.env.PROXY_POOL ? process.env.PROXY_POOL.split(',').map(p => p.trim()) : []; | |
| let proxyIndex = 0; | |
| function getNextProxy() { | |
| if (PROXY_POOL.length === 0) return null; | |
| const proxy = PROXY_POOL[proxyIndex]; | |
| proxyIndex = (proxyIndex + 1) % PROXY_POOL.length; | |
| return proxy; | |
| } | |
| // Gold-Standard Fetch Timeout Utility with Proxy Rotation | |
| async function fetchWithTimeout(url, options = {}) { | |
| const { timeout = 5000, ...fetchOptions } = options; | |
| const controller = new AbortController(); | |
| const id = setTimeout(() => controller.abort(), timeout); | |
| // Dynamic proxy injection | |
| const proxy = getNextProxy(); | |
| if (proxy && !fetchOptions.agent) { | |
| console.log(`[Proxy Rotator] Directing request through: ${proxy}`); | |
| fetchOptions.agent = new HttpsProxyAgent(proxy); | |
| } | |
| try { | |
| const response = await fetch(url, { | |
| ...fetchOptions, | |
| signal: controller.signal | |
| }); | |
| clearTimeout(id); | |
| return response; | |
| } catch (err) { | |
| clearTimeout(id); | |
| throw err; | |
| } | |
| } | |
| // Memory cache for resolved stream URLs to handle range/concurrency requests | |
| const streamCache = new Map(); | |
| const CACHE_TTL_MS = 120 * 60 * 1000; // 120 minutes (2 hours) to minimize upstream API quota usage | |
| // ── Bot-side Telegram-channel cache (LONG-TERM, persistent) ──────────────── | |
| // The AriseRobot bot runs a small aiohttp sidecar that maps yt_id → a | |
| // Cloudflare-R2-hosted MP3 (mirrored from the bot's Telegram cache | |
| // channel). When a song is cached, we can serve the R2 URL directly and | |
| // skip the entire 5-layer resolution cascade. Hits are essentially free; | |
| // misses fall through to the cascade unchanged. | |
| const BOT_CACHE_URL = (process.env.CACHE_SERVER_URL || 'http://127.0.0.1:7861').replace(/\/$/, ''); | |
| const BOT_CACHE_TOKEN = process.env.CACHE_SERVER_TOKEN || 'arise-music-cache'; | |
| const BOT_CACHE_TIMEOUT_MS = parseInt(process.env.CACHE_SERVER_TIMEOUT_MS || '2500', 10); | |
| async function checkBotCache(videoId) { | |
| if (!videoId) return null; | |
| const url = `${BOT_CACHE_URL}/cache/lookup?yt_id=${encodeURIComponent(videoId)}`; | |
| const controller = new AbortController(); | |
| const id = setTimeout(() => controller.abort(), BOT_CACHE_TIMEOUT_MS); | |
| try { | |
| const res = await fetch(url, { | |
| method: 'GET', | |
| headers: { Authorization: `Bearer ${BOT_CACHE_TOKEN}` }, | |
| signal: controller.signal, | |
| }); | |
| if (res.status === 404) return null; | |
| if (!res.ok) { | |
| console.warn(`[BotCache] lookup ${videoId} status=${res.status}`); | |
| return null; | |
| } | |
| const data = await res.json(); | |
| if (data && data.hit) { | |
| console.log(`[BotCache] ✅ HIT for ${videoId} (msg=${data.messageId})`); | |
| return data; | |
| } | |
| return null; | |
| } catch (err) { | |
| // Bot not running, slow, or unreachable → fall through to cascade. | |
| if (err.name !== 'AbortError') { | |
| console.warn(`[BotCache] lookup ${videoId} failed: ${err.message}`); | |
| } | |
| return null; | |
| } finally { | |
| clearTimeout(id); | |
| } | |
| } | |
| // ── Bot-cache audio proxy ────────────────────────────────────────────────── | |
| // Streams the cached audio from the bot's /cache/audio endpoint and | |
| // re-serves it to the public. The node_proxy becomes the streaming | |
| // hot path — that's the trade-off for not using an external CDN like R2. | |
| // | |
| // In-memory LRU (~50 MB cap) avoids hitting the bot for repeat | |
| // requests of the same track. The bot already keeps its own on-disk | |
| // copy in scratch/music_cache_readout/, so this is just a second-tier | |
| // cache to absorb the bursty Range-request pattern from <audio>. | |
| const AUDIO_CACHE = new Map(); | |
| const AUDIO_CACHE_CAP = 50 * 1024 * 1024; // 50 MB total | |
| let audioCacheBytes = 0; | |
| async function proxyBotAudio(videoId, req, res) { | |
| // LRU: refresh key order on access | |
| if (AUDIO_CACHE.has(videoId)) { | |
| const entry = AUDIO_CACHE.get(videoId); | |
| AUDIO_CACHE.delete(videoId); | |
| AUDIO_CACHE.set(videoId, entry); | |
| return sendCachedAudio(entry, req, res); | |
| } | |
| const url = `${BOT_CACHE_URL}/cache/audio?yt_id=${encodeURIComponent(videoId)}`; | |
| const fetchHeaders = {}; | |
| if (req.headers.range) { | |
| fetchHeaders['Range'] = req.headers.range; | |
| } | |
| const controller = new AbortController(); | |
| const id = setTimeout(() => controller.abort(), 30_000); | |
| let upstream; | |
| try { | |
| upstream = await fetch(url, { | |
| method: 'GET', | |
| headers: { Authorization: `Bearer ${BOT_CACHE_TOKEN}`, ...fetchHeaders }, | |
| signal: controller.signal, | |
| }); | |
| } catch (err) { | |
| clearTimeout(id); | |
| console.warn(`[BotCache] audio fetch ${videoId} failed: ${err.message}`); | |
| if (!res.headersSent) { | |
| res.status(502).json({ error: `Bot cache unreachable: ${err.message}` }); | |
| } | |
| return; | |
| } | |
| clearTimeout(id); | |
| if (!upstream.ok && upstream.status !== 206) { | |
| if (upstream.status === 404) { | |
| console.log(`[BotCache] audio ${videoId} → 404 (miss)`); | |
| } else { | |
| console.warn(`[BotCache] audio ${videoId} → ${upstream.status}`); | |
| } | |
| const body = await upstream.text().catch(() => ''); | |
| res.status(upstream.status).json({ error: body || `Bot cache ${upstream.status}` }); | |
| return; | |
| } | |
| const contentType = upstream.headers.get('content-type') || 'audio/mpeg'; | |
| const contentLength = parseInt(upstream.headers.get('content-length') || '0', 10); | |
| // Build a tiny CORS-friendly header bag | |
| const headers = { | |
| 'Content-Type': contentType, | |
| 'Accept-Ranges': 'bytes', | |
| 'Cache-Control': 'public, max-age=3600', | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'GET, OPTIONS', | |
| 'Access-Control-Allow-Headers': 'Range, Content-Type', | |
| 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', | |
| }; | |
| for (const h of ['content-length', 'content-range']) { | |
| const v = upstream.headers.get(h); | |
| if (v) headers[h] = v; | |
| } | |
| res.writeHead(upstream.status, headers); | |
| // Full-body cache: only when the response is small enough to keep in | |
| // the in-memory budget. We buffer, then either send or set up streaming | |
| // with an "on close" cache write. | |
| if (contentLength > 0 && contentLength <= 8 * 1024 * 1024) { | |
| try { | |
| const ab = await upstream.arrayBuffer(); | |
| const buf = Buffer.from(ab); | |
| res.end(buf); | |
| // Promote to LRU cache if there's still room | |
| evictIfNeeded(contentLength); | |
| if (audioCacheBytes + contentLength <= AUDIO_CACHE_CAP) { | |
| AUDIO_CACHE.set(videoId, { buf, contentType }); | |
| audioCacheBytes += contentLength; | |
| } | |
| return; | |
| } catch (err) { | |
| if (!res.headersSent) { | |
| res.status(502).json({ error: `Bot cache read failed: ${err.message}` }); | |
| } | |
| return; | |
| } | |
| } | |
| // Large response: stream straight through (no in-memory cache). | |
| const reader = upstream.body.getReader(); | |
| const push = async () => { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| res.write(value); | |
| } | |
| res.end(); | |
| }; | |
| push().catch(err => { | |
| console.warn(`[BotCache] stream ${videoId} error: ${err.message}`); | |
| res.end(); | |
| }); | |
| req.on('close', () => reader.cancel()); | |
| } | |
| function sendCachedAudio(entry, req, res) { | |
| const headers = { | |
| 'Content-Type': entry.contentType || 'audio/mpeg', | |
| 'Content-Length': String(entry.buf.length), | |
| 'Accept-Ranges': 'bytes', | |
| 'Cache-Control': 'public, max-age=3600', | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Headers': 'Range, Content-Type', | |
| 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', | |
| }; | |
| // Honor Range for already-buffered responses | |
| if (req.headers.range && entry.buf) { | |
| const m = /^bytes=(\d+)-(\d+)?$/.exec(req.headers.range); | |
| if (m) { | |
| const start = parseInt(m[1], 10); | |
| const end = m[2] ? parseInt(m[2], 10) : entry.buf.length - 1; | |
| if (start <= end && end < entry.buf.length) { | |
| const slice = entry.buf.subarray(start, end + 1); | |
| headers['Content-Range'] = `bytes ${start}-${end}/${entry.buf.length}`; | |
| headers['Content-Length'] = String(slice.length); | |
| res.writeHead(206, headers); | |
| res.end(slice); | |
| return; | |
| } | |
| } | |
| } | |
| res.writeHead(200, headers); | |
| res.end(entry.buf); | |
| } | |
| function evictIfNeeded(incoming) { | |
| while (audioCacheBytes + incoming > AUDIO_CACHE_CAP && AUDIO_CACHE.size > 0) { | |
| const oldestKey = AUDIO_CACHE.keys().next().value; | |
| const oldest = AUDIO_CACHE.get(oldestKey); | |
| if (!oldest) break; | |
| audioCacheBytes -= oldest.buf.length; | |
| AUDIO_CACHE.delete(oldestKey); | |
| } | |
| } | |
| // Health check | |
| app.get('/health', (req, res) => { | |
| try { | |
| res.status(200).json({ | |
| status: 'healthy', | |
| service: 'node-ytdl-proxy', | |
| timestamp: new Date().toISOString() | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Public cache audio proxy. Streams from the bot's /cache/audio endpoint | |
| // over loopback. The node_proxy is the public-facing endpoint; the bot | |
| // stays on a private network. Hit LRU is 50 MB total. | |
| app.get('/cache/audio', async (req, res) => { | |
| const ytId = (req.query.yt_id || '').trim(); | |
| if (!ytId) { | |
| return res.status(400).json({ error: 'Missing required query parameter "yt_id"' }); | |
| } | |
| return proxyBotAudio(ytId, req, res); | |
| }); | |
| // LAYER 1: Multi-Client Innertube API Extractor — race ANDROID_MUSIC + ANDROID_VR + TVHTML5 | |
| // Returns the first direct audio URL any client gives. Skips signatureCipher entries | |
| // (those require n/sig deciphering which the yt-dlp/pytubefix fallback handles). | |
| async function innertubeFetchOnce(videoId, client) { | |
| const url = "https://www.youtube.com/youtubei/v1/player"; | |
| const payload = { | |
| videoId, | |
| context: { | |
| client: { | |
| clientName: client.name, | |
| clientVersion: client.version, | |
| hl: "en", | |
| gl: "US" | |
| } | |
| }, | |
| contentCheckOk: true, | |
| racyCheckOk: true | |
| }; | |
| try { | |
| const response = await fetchWithTimeout(url, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "User-Agent": client.userAgent | |
| }, | |
| body: JSON.stringify(payload), | |
| timeout: 5000 | |
| }); | |
| if (!response.ok) { | |
| console.warn(`[Innertube:${client.name}] status ${response.status}`); | |
| return null; | |
| } | |
| const data = await response.json(); | |
| const formats = data?.streamingData?.adaptiveFormats || []; | |
| const directFormats = formats.filter(f => f.url && !f.signatureCipher); | |
| // Prefer format 140 (128kbps AAC m4a) — broadest device compatibility | |
| let bestAudio = directFormats.find(f => f.mimeType?.includes("audio/mp4") && f.itag === 140) | |
| || directFormats.find(f => f.mimeType?.includes("audio/mp4")) | |
| || directFormats.find(f => f.mimeType?.includes("audio/webm")) | |
| || directFormats.find(f => f.mimeType?.includes("audio/")); | |
| if (bestAudio && bestAudio.url) { | |
| console.log(`[Innertube:${client.name}] ✅ direct stream for ${videoId}`); | |
| return { url: bestAudio.url, contentLength: bestAudio.contentLength }; | |
| } | |
| return null; | |
| } catch (err) { | |
| console.warn(`[Innertube:${client.name}] failed: ${err.message}`); | |
| return null; | |
| } | |
| } | |
| async function extractDirectStreamUrl(videoId) { | |
| console.log(`[Innertube] Racing ${INNERTUBE_CLIENTS.length} clients for: ${videoId}`); | |
| const attempts = INNERTUBE_CLIENTS.map(c => innertubeFetchOnce(videoId, c)); | |
| const results = await Promise.all(attempts); | |
| const winner = results.find(r => r && r.url); | |
| if (winner) { | |
| return winner; | |
| } | |
| console.warn("[Innertube] All clients failed"); | |
| return null; | |
| } | |
| // Spawn pytubefix Python extractor | |
| async function resolvePytubefix(videoId) { | |
| try { | |
| console.log(`[Pytubefix] Spawning pytubefix extraction for: ${videoId}`); | |
| const cmd = `python3 resolve_pytubefix.py "${videoId}"`; | |
| const { stdout } = await execAsync(cmd, { timeout: 6000 }); | |
| const data = JSON.parse(stdout); | |
| if (data && data.url) { | |
| console.log(`[Pytubefix] Successfully resolved stream!`); | |
| return { | |
| url: data.url, | |
| contentLength: data.contentLength || null | |
| }; | |
| } | |
| if (data && data.error) { | |
| console.warn(`[Pytubefix] Extraction warning: ${data.error}`); | |
| } | |
| return null; | |
| } catch (err) { | |
| console.error(`[Pytubefix] Execution failed: ${err.message}`); | |
| return null; | |
| } | |
| } | |
| // LAYER 2: Double-Engine CLI Extractor (Pytubefix Stage 2.1 & yt-dlp Stage 2.2) | |
| async function resolveYtdl(videoId) { | |
| // Stage 2.1: Try pytubefix First (Extremely robust & native JS-free decryption) | |
| const pytubeResult = await resolvePytubefix(videoId); | |
| if (pytubeResult) return pytubeResult; | |
| // Stage 2.2: Fall back to direct yt-dlp CLI | |
| const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; | |
| try { | |
| console.log(`[yt-dlp CLI] Stage 2.2: Falling back to direct yt-dlp extraction for: ${videoId}`); | |
| let cmd = `yt-dlp -j --format "140/ba/best" --socket-timeout 10 --no-check-certificate --legacy-server-connect --remote-components ejs:github "${videoUrl}"`; | |
| if (fs.existsSync(COOKIES_PATH)) { | |
| cmd += ` --cookies "${COOKIES_PATH}"`; | |
| } | |
| const { stdout } = await execAsync(cmd, { timeout: 15000 }); | |
| const data = JSON.parse(stdout); | |
| if (data && data.url) { | |
| console.log(`[yt-dlp CLI] Stage 2.2: DIRECT extraction succeeded!`); | |
| return { | |
| url: data.url, | |
| contentLength: data.filesize || data.filesize_approx || null | |
| }; | |
| } | |
| return null; | |
| } catch (err) { | |
| console.error(`[yt-dlp CLI] Stage 2.2: DIRECT extraction failed: ${err.message}`); | |
| return null; | |
| } | |
| } | |
| // LAYER 3: Cobalt API Fallback — race ALL instances in parallel, first success wins | |
| async function resolveCobalt(videoId) { | |
| const promises = COBALT_INSTANCES.map(async (api) => { | |
| try { | |
| console.log(`[Cobalt] Attempting resolution via: ${api} for: ${videoId}`); | |
| const res = await fetchWithTimeout(`${api}/api/json`, { | |
| method: 'POST', | |
| headers: { | |
| 'Accept': 'application/json', | |
| 'Content-Type': 'application/json', | |
| 'User-Agent': 'Mozilla/5.0' | |
| }, | |
| body: JSON.stringify({ | |
| url: `https://www.youtube.com/watch?v=${videoId}`, | |
| downloadMode: 'audio', | |
| audioFormat: 'best' | |
| }), | |
| timeout: 5000 | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| if (data && data.url) { | |
| console.log(`[Cobalt] Successfully resolved stream from ${api}`); | |
| return { url: data.url }; | |
| } | |
| } | |
| throw new Error(`Status ${res.status}`); | |
| } catch (err) { | |
| console.warn(`[Cobalt] API ${api} failed: ${err.message}`); | |
| throw err; | |
| } | |
| }); | |
| try { | |
| return await Promise.any(promises); | |
| } catch (err) { | |
| console.warn(`[Cobalt] All ${COBALT_INSTANCES.length} instances exhausted.`); | |
| return null; | |
| } | |
| } | |
| // LAYER 4: Invidious Proxy Fallback — race ALL instances in parallel | |
| async function resolveInvidious(videoId) { | |
| const promises = INVIDIOUS_INSTANCES.map(async (instance) => { | |
| const streamUrl = `${instance}/latest_version?id=${videoId}&itag=140&local=true`; | |
| try { | |
| console.log(`[Invidious] Attempting proxy stream check via: ${streamUrl}`); | |
| const resp = await fetchWithTimeout(streamUrl, { | |
| method: 'HEAD', | |
| timeout: 5000 | |
| }); | |
| if (resp.ok && resp.headers.get('content-type')?.includes('audio')) { | |
| console.log(`[Invidious] Successfully found active stream source: ${streamUrl}`); | |
| return { | |
| url: streamUrl, | |
| contentLength: resp.headers.get('content-length') | |
| }; | |
| } | |
| throw new Error('Bad content-type or status'); | |
| } catch (err) { | |
| console.warn(`[Invidious] Instance ${instance} failed: ${err.message}`); | |
| throw err; | |
| } | |
| }); | |
| try { | |
| return await Promise.any(promises); | |
| } catch (err) { | |
| console.warn(`[Invidious] All ${INVIDIOUS_INSTANCES.length} instances exhausted.`); | |
| return null; | |
| } | |
| } | |
| // LAYER 5: Piped API Fallback — race ALL Piped instances, return lowest-bitrate audio stream | |
| async function resolvePiped(videoId) { | |
| const promises = PIPED_INSTANCES.map(async (instance) => { | |
| try { | |
| console.log(`[Piped] Resolving via: ${instance}/streams/${videoId}`); | |
| const resp = await fetchWithTimeout(`${instance}/streams/${videoId}`, { | |
| headers: { 'User-Agent': 'Mozilla/5.0' }, | |
| timeout: 5000 | |
| }); | |
| if (!resp.ok) throw new Error(`Status ${resp.status}`); | |
| const data = await resp.json(); | |
| const streams = data?.audioStreams || []; | |
| if (streams.length === 0) throw new Error('No audio streams'); | |
| // Pick the lowest bitrate for fastest first-byte to the audio element | |
| const sorted = [...streams].sort((a, b) => (a.bitrate || 0) - (b.bitrate || 0)); | |
| const audio = sorted.find(s => s.mimeType?.includes('audio/mp4')) || sorted[0]; | |
| if (!audio?.url) throw new Error('No audio URL in response'); | |
| console.log(`[Piped] ✅ Resolved from ${instance}`); | |
| return { url: audio.url, contentLength: null }; | |
| } catch (err) { | |
| console.warn(`[Piped] ${instance} failed: ${err.message}`); | |
| throw err; | |
| } | |
| }); | |
| try { | |
| return await Promise.any(promises); | |
| } catch (err) { | |
| console.warn(`[Piped] All ${PIPED_INSTANCES.length} instances exhausted.`); | |
| return null; | |
| } | |
| } | |
| // Helper to fetch robust YouTube Metadata via YouTube's official, unblocked oEmbed API | |
| async function getVideoMetadata(videoId) { | |
| try { | |
| console.log(`[Metadata] Fetching oEmbed for video: ${videoId}`); | |
| const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; | |
| const resp = await fetchWithTimeout(oembedUrl, { timeout: 3000 }); | |
| if (resp.ok) { | |
| const data = await resp.json(); | |
| return { | |
| title: data.title || 'YouTube Audio Feed', | |
| duration: 0, | |
| thumbnail: data.thumbnail_url || `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, | |
| artist: data.author_name || 'Various Artists' | |
| }; | |
| } | |
| } catch (err) { | |
| console.warn(`[Metadata] oEmbed lookup failed: ${err.message}`); | |
| } | |
| // Safe fallback (avoid youtube-yts scraping as it gets blocked/hangs in cloud environments) | |
| return { | |
| title: 'YouTube Audio Feed', | |
| duration: 0, | |
| thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, | |
| artist: 'Various Artists' | |
| }; | |
| } | |
| // Master Resolver Orchestrator — runs the cheaper/faster strategies in a tiered cascade, | |
| // prioritizing high-quality direct CDN URLs and falling back sequentially to avoid spamming subrequests. | |
| async function masterResolve(videoId) { | |
| // Group 1: High-Quality Direct APIs raced in parallel (IZGIV, Innertube, Cobalt) | |
| try { | |
| console.log(`[Resolve] Group 1: Racing IZGIV, Innertube, and Cobalt in parallel for ${videoId}...`); | |
| const res = await Promise.any([ | |
| resolveIzgivBotto(videoId).then(r => { | |
| if (r && r.url) return r; | |
| throw new Error('Izgiv failed'); | |
| }), | |
| extractDirectStreamUrl(videoId).then(r => { | |
| if (r && r.url) return r; | |
| throw new Error('Innertube failed'); | |
| }), | |
| resolveCobalt(videoId).then(r => { | |
| if (r && r.url) return r; | |
| throw new Error('Cobalt failed'); | |
| }) | |
| ]); | |
| if (res && res.url) { | |
| console.log(`[Resolve] ✅ Group 1 resolved stream for ${videoId}`); | |
| return res; | |
| } | |
| } catch (err) { | |
| console.warn(`[Resolve] Group 1 failed for ${videoId}: ${err.message}. Trying Group 2...`); | |
| } | |
| // Group 2: Public Pools raced in parallel (Piped, Invidious) | |
| try { | |
| console.log(`[Resolve] Group 2: Racing Piped and Invidious in parallel for ${videoId}...`); | |
| const res = await Promise.any([ | |
| resolvePiped(videoId).then(r => { | |
| if (r && r.url) return r; | |
| throw new Error('Piped failed'); | |
| }), | |
| resolveInvidious(videoId).then(r => { | |
| if (r && r.url) return r; | |
| throw new Error('Invidious failed'); | |
| }) | |
| ]); | |
| if (res && res.url) { | |
| console.log(`[Resolve] ✅ Group 2 resolved stream for ${videoId}`); | |
| return res; | |
| } | |
| } catch (err) { | |
| console.warn(`[Resolve] Group 2 failed for ${videoId}: ${err.message}`); | |
| } | |
| return null; | |
| } | |
| // Search YouTube | |
| app.get('/api/search', async (req, res) => { | |
| const query = req.query.q; | |
| if (!query) { | |
| return res.status(400).json({ error: 'Missing search query parameter "q"' }); | |
| } | |
| try { | |
| console.log(`🔎 Searching YouTube for: ${query}`); | |
| const searchResult = await yts({ query, hl: 'en', gl: 'US' }); | |
| const videos = (searchResult.videos || []).slice(0, 10).map(video => ({ | |
| id: video.videoId, | |
| title: video.title, | |
| artist: video.author.name, | |
| coverUrl: video.thumbnail || video.image, | |
| duration: video.seconds, | |
| url: video.url | |
| })); | |
| console.log(`✅ Found ${videos.length} search results`); | |
| res.status(200).json({ results: videos }); | |
| } catch (err) { | |
| console.error('❌ Search failed:', err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // Resolve Stream URL | |
| app.get('/api/resolve', async (req, res) => { | |
| try { | |
| const videoId = req.query.v || req.query.url; | |
| if (!videoId) { | |
| return res.status(400).json({ error: 'Missing parameter "v" or "url"' }); | |
| } | |
| const cleanId = videoId.startsWith('http') ? ytdl.getVideoID(videoId) : videoId; | |
| // Check if cached | |
| const cached = streamCache.get(cleanId); | |
| if (cached && Date.now() < cached.expiresAt) { | |
| console.log(`🎯 Cache HIT for resolve: ${cleanId}`); | |
| const meta = await getVideoMetadata(cleanId).catch(() => ({})); | |
| return res.status(200).json({ | |
| url: cached.url, | |
| title: meta.title || 'YouTube Audio Feed', | |
| duration: meta.duration || 0, | |
| thumbnail: meta.thumbnail || `https://i.ytimg.com/vi/${cleanId}/hqdefault.jpg`, | |
| artist: meta.artist || 'Various Artists', | |
| format: 'm4a', | |
| source: cached.source || 'memory' | |
| }); | |
| } | |
| // ── LAYER -1 (PRIMARY): Bot-side Telegram-channel cache ── | |
| // The bot maintains a yt_id → Telegram-channel → /cache/audio cache. | |
| // On hit we skip the entire 5-layer cascade (YouTube, IZGIV APIs, | |
| // Cobalt, etc.). Bot-preheat is run when the user types /play, so by | |
| // the time playback reaches the track, the cache is almost always | |
| // warm. The resolved URL points at this proxy's own /cache/audio | |
| // endpoint, which streams from the bot's loopback. | |
| const botHit = await checkBotCache(cleanId).catch(() => null); | |
| if (botHit) { | |
| const proxyAudioUrl = `/cache/audio?yt_id=${encodeURIComponent(cleanId)}`; | |
| streamCache.set(cleanId, { | |
| url: proxyAudioUrl, | |
| expiresAt: Date.now() + CACHE_TTL_MS, | |
| source: 'bot_cache' | |
| }); | |
| console.log(`[Resolve] Serving from bot cache: ${proxyAudioUrl}`); | |
| return res.status(200).json({ | |
| url: proxyAudioUrl, | |
| title: botHit.title || 'YouTube Audio Feed', | |
| duration: botHit.duration || 0, | |
| thumbnail: `https://i.ytimg.com/vi/${cleanId}/hqdefault.jpg`, | |
| artist: botHit.artist || 'Various Artists', | |
| format: 'mp3', | |
| source: 'bot_cache', | |
| cached: true | |
| }); | |
| } | |
| // Resolve stream and metadata in parallel safely | |
| const [resolved, meta] = await Promise.all([ | |
| masterResolve(cleanId).catch(err => { | |
| console.error('Error in masterResolve:', err); | |
| return null; | |
| }), | |
| getVideoMetadata(cleanId).catch(err => { | |
| console.error('Error in getVideoMetadata:', err); | |
| return { | |
| title: 'YouTube Audio Feed', | |
| duration: 0, | |
| thumbnail: `https://i.ytimg.com/vi/${cleanId}/hqdefault.jpg`, | |
| artist: 'Various Artists' | |
| } | |
| }) | |
| ]); | |
| if (!resolved || !resolved.url) { | |
| return res.status(500).json({ error: 'Failed to extract downloadable audio stream from all stages' }); | |
| } | |
| // Cache the resolved URL! | |
| streamCache.set(cleanId, { | |
| url: resolved.url, | |
| expiresAt: Date.now() + CACHE_TTL_MS, | |
| source: 'cascade' | |
| }); | |
| res.status(200).json({ | |
| url: resolved.url, | |
| title: meta.title, | |
| duration: meta.duration || resolved.duration || 0, | |
| thumbnail: meta.thumbnail, | |
| artist: meta.artist, | |
| format: 'm4a', | |
| source: 'cascade' | |
| }); | |
| } catch (err) { | |
| console.error('❌ Master resolution failed:', err); | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| // PREHEAT — resolve and cache stream URL without proxying the actual audio bytes. | |
| // Called by the CF Worker (or frontend) to warm the cache so that when the | |
| // queue advances, /api/stream serves from cache instantly. | |
| app.get('/api/preheat', async (req, res) => { | |
| try { | |
| const videoId = req.query.v || req.query.url; | |
| if (!videoId) { | |
| return res.status(400).json({ error: 'Missing parameter "v" or "url"' }); | |
| } | |
| const cleanId = videoId.startsWith('http') ? ytdl.getVideoID(videoId) : videoId; | |
| const wasCached = (() => { | |
| const c = streamCache.get(cleanId); | |
| return !!(c && Date.now() < c.expiresAt); | |
| })(); | |
| if (wasCached) { | |
| console.log(`🔥 Preheat cache HIT for ${cleanId}`); | |
| return res.status(200).json({ success: true, alreadyCached: true, strategy: 'cache' }); | |
| } | |
| const resolved = await masterResolve(cleanId).catch(err => { | |
| console.error(`[Preheat] masterResolve error for ${cleanId}:`, err); | |
| return null; | |
| }); | |
| if (!resolved || !resolved.url) { | |
| console.warn(`[Preheat] All layers failed for ${cleanId}`); | |
| return res.status(404).json({ success: false, error: 'All resolvers failed' }); | |
| } | |
| streamCache.set(cleanId, { | |
| url: resolved.url, | |
| expiresAt: Date.now() + CACHE_TTL_MS | |
| }); | |
| console.log(`🔥 Preheated ${cleanId} (cached for ${CACHE_TTL_MS / 60000} min)`); | |
| res.status(200).json({ success: true, alreadyCached: false, strategy: 'fresh' }); | |
| } catch (err) { | |
| console.error('❌ Preheat failed:', err); | |
| res.status(500).json({ success: false, error: err.message }); | |
| } | |
| }); | |
| // Stream Audio Direct Proxy | |
| app.get('/api/stream', async (req, res) => { | |
| try { | |
| const videoId = req.query.v || req.query.url; | |
| if (!videoId) { | |
| return res.status(400).json({ error: 'Missing parameter "v" or "url"' }); | |
| } | |
| const cleanId = videoId.startsWith('http') ? ytdl.getVideoID(videoId) : videoId; | |
| console.log(`🎧 Master S-Rank streaming request for: ${cleanId}`); | |
| // ── LAYER -1 (PRIMARY): Bot-side Telegram-channel cache ── | |
| // Skip the entire 5-layer cascade on a hit. This proxy exposes its | |
| // own /cache/audio endpoint that streams from the bot's loopback | |
| // (see proxyBotAudio above). The redirect target is on the same | |
| // public host, so the browser stays connected to one origin. | |
| const botHit = await checkBotCache(cleanId).catch(() => null); | |
| if (botHit) { | |
| const audioProxy = `/cache/audio?yt_id=${encodeURIComponent(cleanId)}`; | |
| console.log(`[Stream] Bot cache HIT → 302 to ${audioProxy}`); | |
| return res.redirect(302, audioProxy); | |
| } | |
| // Fast-path direct redirect to source CDN if requested | |
| if (req.query.redirect === 'true') { | |
| console.log(`⚡ Fast-path redirect requested for: ${cleanId}`); | |
| const cached = streamCache.get(cleanId); | |
| if (cached && Date.now() < cached.expiresAt) { | |
| console.log(`🎯 Cache HIT! Fast-redirecting to cached stream URL: ${cached.url}`); | |
| return res.redirect(302, cached.url); | |
| } | |
| const resolved = await masterResolve(cleanId).catch(() => null); | |
| if (resolved && resolved.url) { | |
| streamCache.set(cleanId, { | |
| url: resolved.url, | |
| expiresAt: Date.now() + CACHE_TTL_MS | |
| }); | |
| console.log(`✅ Success! Fast-redirecting to resolved stream URL: ${resolved.url}`); | |
| return res.redirect(302, resolved.url); | |
| } | |
| } | |
| // Handle HEAD requests immediately for pre-flight stability | |
| if (req.method === 'HEAD') { | |
| res.setHeader('Content-Type', 'audio/mp4'); | |
| res.setHeader('Accept-Ranges', 'bytes'); | |
| res.setHeader('Cache-Control', 'public, max-age=3600'); | |
| return res.status(200).end(); | |
| } | |
| const rangeHeader = req.headers.range; | |
| // --- Stream Cache HIT --- | |
| const cached = streamCache.get(cleanId); | |
| if (cached && Date.now() < cached.expiresAt) { | |
| console.log(`🎯 Cache HIT! Serving cached stream URL inside HF Node Proxy for: ${cleanId}`); | |
| try { | |
| const responseHeaders = { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'GET, OPTIONS', | |
| 'Access-Control-Allow-Headers': 'Range, Content-Type', | |
| 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', | |
| 'Accept-Ranges': 'bytes', | |
| 'Cache-Control': 'public, max-age=3600', | |
| 'Content-Type': 'audio/mp4' | |
| }; | |
| const fetchHeaders = { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' | |
| }; | |
| if (rangeHeader) { | |
| fetchHeaders['Range'] = rangeHeader; | |
| } | |
| const streamResponse = await fetchWithTimeout(cached.url, { | |
| headers: fetchHeaders, | |
| timeout: 15000 | |
| }); | |
| const contentType = streamResponse.headers.get('content-type') || ''; | |
| if (streamResponse.ok && !contentType.toLowerCase().includes('text/html')) { | |
| // Mirror content headers | |
| for (const h of ['content-type', 'content-length', 'content-range']) { | |
| const val = streamResponse.headers.get(h); | |
| if (val) responseHeaders[h] = val; | |
| } | |
| res.writeHead(streamResponse.status, responseHeaders); | |
| const reader = streamResponse.body.getReader(); | |
| const push = async () => { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| res.write(value); | |
| } | |
| res.end(); | |
| }; | |
| push().catch(err => { | |
| console.error('❌ Pipelining error during read on cached stream:', err); | |
| res.end(); | |
| }); | |
| req.on('close', () => { | |
| reader.cancel(); | |
| }); | |
| return; | |
| } else { | |
| console.warn(`⚠️ Cached stream URL returned non-OK status ${streamResponse.status}. Invalidating cache.`); | |
| streamCache.delete(cleanId); | |
| } | |
| } catch (err) { | |
| console.error(`❌ Cached stream fetch failed inside HF Node Proxy: ${err.message}. Invalidating cache.`); | |
| streamCache.delete(cleanId); | |
| } | |
| } | |
| const resolved = await masterResolve(cleanId).catch(err => { | |
| console.error('Error in masterResolve inside stream:', err); | |
| return null; | |
| }); | |
| if (!resolved || !resolved.url) { | |
| return res.status(500).json({ error: 'Failed to extract audio stream from all stages' }); | |
| } | |
| // Cache the resolved working URL! | |
| streamCache.set(cleanId, { | |
| url: resolved.url, | |
| expiresAt: Date.now() + CACHE_TTL_MS | |
| }); | |
| // Configure response headers matching cross-origin standards for WebViews | |
| const responseHeaders = { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'GET, OPTIONS', | |
| 'Access-Control-Allow-Headers': 'Range, Content-Type', | |
| 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', | |
| 'Accept-Ranges': 'bytes', | |
| 'Cache-Control': 'public, max-age=3600', | |
| 'Content-Type': 'audio/mp4' | |
| }; | |
| const fetchHeaders = { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' | |
| }; | |
| if (rangeHeader) { | |
| fetchHeaders['Range'] = rangeHeader; | |
| console.log(`⚡ Proxying stream with range: ${rangeHeader}`); | |
| } | |
| // Proxy the stream response directly from the resolved URL | |
| const streamResponse = await fetchWithTimeout(resolved.url, { | |
| headers: fetchHeaders, | |
| timeout: 15000 | |
| }); | |
| const contentType = streamResponse.headers.get('content-type') || ''; | |
| const ct = contentType.toLowerCase(); | |
| if (!streamResponse.ok || ct.includes('text/html') || ct.includes('application/json') || ct.includes('text/plain')) { | |
| console.warn(`⚠️ Upstream stream fetch failed or returned invalid content-type ${contentType} (status: ${streamResponse.status}). Falling back to HTTP 302 redirect directly to source CDN!`); | |
| return res.redirect(302, resolved.url); | |
| } | |
| // Mirror content headers | |
| for (const h of ['content-type', 'content-length', 'content-range']) { | |
| const val = streamResponse.headers.get(h); | |
| if (val) responseHeaders[h] = val; | |
| } | |
| res.writeHead(streamResponse.status, responseHeaders); | |
| // Convert ReadableStream to Node.js stream and pipe to express response | |
| const reader = streamResponse.body.getReader(); | |
| const push = async () => { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| res.write(value); | |
| } | |
| res.end(); | |
| }; | |
| push().catch(err => { | |
| console.error('❌ Pipelining error during read:', err); | |
| res.end(); | |
| }); | |
| req.on('close', () => { | |
| console.log('🔌 Client disconnected, closing reader'); | |
| reader.cancel(); | |
| }); | |
| } catch (err) { | |
| console.error('❌ Master streaming error:', err); | |
| if (!res.headersSent) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| } | |
| }); | |
| app.get('/api/test-ytdlp', async (req, res) => { | |
| try { | |
| let cmd = req.query.cmd; | |
| if (cmd) { | |
| console.log(`[Diagnostic] Executing custom command: ${cmd}`); | |
| const { stdout, stderr } = await execAsync(cmd).catch(err => ({ stdout: '', stderr: err.message })); | |
| return res.json({ command: cmd, stdout, stderr }); | |
| } | |
| const videoId = req.query.v || 'jotdDIy1F90'; | |
| console.log(`[Diagnostic] Running double-engine test for: ${videoId}`); | |
| // 1. Test pytubefix | |
| const pytubeCmd = `python3 resolve_pytubefix.py "${videoId}"`; | |
| const pytubeRes = await execAsync(pytubeCmd).catch(err => ({ stdout: '', stderr: err.message })); | |
| // 2. Test yt-dlp | |
| const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; | |
| const ytdlpCmd = `yt-dlp -j --format "140/ba/best" --socket-timeout 10 --no-check-certificate --legacy-server-connect --remote-components ejs:github "${videoUrl}"` + | |
| (fs.existsSync(COOKIES_PATH) ? ` --cookies "${COOKIES_PATH}"` : ''); | |
| const ytdlpRes = await execAsync(ytdlpCmd).catch(err => ({ stdout: '', stderr: err.message })); | |
| res.json({ | |
| videoId, | |
| pytubefix: { | |
| command: pytubeCmd, | |
| stdout: pytubeRes.stdout, | |
| stderr: pytubeRes.stderr | |
| }, | |
| ytdlp: { | |
| command: ytdlpCmd, | |
| stdout: ytdlpRes.stdout.substring(0, 1000), | |
| stderr: ytdlpRes.stderr.substring(0, 1000) | |
| } | |
| }); | |
| } catch (err) { | |
| res.status(500).json({ error: err.message }); | |
| } | |
| }); | |
| app.listen(PORT, '0.0.0.0', () => { | |
| console.log(`🚀 Node S-Rank YT-DLP / Innertube Proxy listening on port ${PORT}`); | |
| }); | |