import express, { json } from "express"; import cors from "cors"; import { chromium } from "playwright"; import pLimit from "p-limit"; import BrowserPool from './pool/BrowserPool.js'; import LRU from 'lru-cache'; const app = express(); const PORT = process.env.PORT || 3000; app.use(cors()); app.use(json()); const PROVIDERS = [ "https://vidsrc.xyz", "https://vidsrc.in", "https://vidsrc.pm", "https://vidsrc.net", "https://vidsrc.io", "https://vidsrc.vc", ]; let browserPool; const cache = new LRU({ max: 500, ttl: 15 * 60 * 1000, }); const limit = pLimit(2); // [MODIFIED] scrapeProvider now accepts an AbortSignal to enable cancellation async function scrapeProvider(domain, url, signal) { // 初始检查 if (signal.aborted) { throw new Error('Scraping aborted before starting.'); } console.log(`\n[${domain}] Starting scrape for URL: ${url}`); let hlsUrl = null; const subtitles = []; let browserInstance = null; let context = null; let page = null; const cleanup = async () => { if (page && !page.isClosed()) { page.removeAllListeners(); await page.close().catch(() => {}); } if (context) { await context.close().catch(() => {}); } if (browserInstance) { console.log(`${url} Releasing browser ${browserInstance.id} back to pool.`); await browserPool.release(browserInstance); } }; try { // 检查是否已取消 if (signal.aborted) { throw new Error('Scraping aborted.'); } browserInstance = await browserPool.get(); const browser = browserInstance.browser; console.log(`${url} Acquired browser ${browserInstance.id}`); // 再次检查 if (signal.aborted) { throw new Error('Scraping aborted.'); } context = await browser.newContext({ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", ignoreHTTPSErrors: true }); page = await context.newPage(); // 设置网络拦截 await page.route("**/*", (route) => { // 在网络拦截中也检查 abort 状态 if (signal.aborted) { route.abort(); return; } const reqUrl = route.request().url(); if (!hlsUrl && reqUrl.includes(".m3u8")) { hlsUrl = reqUrl; console.log(`[${domain}] Found HLS URL: ${hlsUrl}`); } if (reqUrl.endsWith(".vtt") || reqUrl.endsWith(".srt") || reqUrl.includes(".vtt") || reqUrl.includes(".srt")) { if (!subtitles.some(s => s.url === reqUrl)) { subtitles.push(reqUrl); console.log(`[${domain}] Found subtitle URL: ${reqUrl}`); } } route.continue(); }); // 改进 frame 监听 page.on("frameattached", (frame) => { const frameUrl = frame.url() || "about:blank"; console.log(`[${domain}] Frame attached: ${frameUrl}`); // 监听 frame 加载完成 // frame.on("load", () => { // if (frame.url() !== "about:blank") { // console.log(`[${domain}] Frame loaded: ${frame.url()}`); // } // }); }); // 检查后再导航 if (signal.aborted) { throw new Error('Scraping aborted.'); } await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20000, signal }); console.log(`[${domain}] Page loaded`); // 检查后再查找元素 if (signal.aborted) { throw new Error('Scraping aborted.'); } const frameDiv = await page.waitForSelector("#the_frame", { timeout: 10000, signal, }); if (frameDiv) { // 检查后再点击 if (signal.aborted) { throw new Error('Scraping aborted.'); } const box = await frameDiv.boundingBox(); if (box) { const clickX = box.x + box.width / 2; const clickY = box.y + box.height / 2; console.log(`[${domain}] Clicking at (${clickX.toFixed(1)}, ${clickY.toFixed(1)})`); await page.mouse.move(clickX, clickY); await page.mouse.click(clickX, clickY); } else { console.warn(`[${domain}] Fallback: clicking via JS`); await page.evaluate(() => { document.querySelector("#the_frame")?.click(); }); } // 检查后再等待响应 if (signal.aborted) { throw new Error('Scraping aborted.'); } if (!hlsUrl) { await page .waitForResponse((resp) => resp.url().includes(".m3u8"), { timeout: 5000, signal, }) .catch((error) => { if (signal.aborted || error.name === 'AbortError') { console.warn(`[${domain}] .m3u8 wait was aborted.`); throw new Error('Scraping aborted.'); } else { console.warn(`[${domain}] .m3u8 request not detected within 5s`); } }); } } else { throw new Error(`#the_frame div not found`); } // 最终检查 if (signal.aborted) { throw new Error('Scraping aborted.'); } if (!hlsUrl) { throw new Error("HLS URL not found"); } return { source_domain: domain, hls_url: hlsUrl, subtitles, error: null }; } catch (error) { if (error.name === 'AbortError' || error.message.includes('aborted') || (signal && signal.aborted)) { console.log(`[${domain}] Scraping was aborted.`); throw error; } console.error(`[${domain}] Error in scrapeProvider: ${error.message}`); throw error; } finally { await cleanup(); } } // [MODIFIED] /extract route now uses Promise.any and AbortController app.get("/extract", async (req, res) => { const type = req.query.type || "movie"; const tmdb_id = req.query.tmdb_id; const season = req.query.season ? parseInt(req.query.season) : undefined; const episode = req.query.episode ? parseInt(req.query.episode) : undefined; if (!tmdb_id) { return res.status(400).json({ success: false, error: "tmdb_id query param is required", }); } if (type === "tv" && (season == null || episode == null)) { return res.status(400).json({ success: false, error: "season and episode query params are required for TV shows", }); } const cacheKey = JSON.stringify(req.query); const cached = cache.get(cacheKey); if (cached) { console.log("Serving from cache"); return res.json(cached); } const urls = PROVIDERS.reduce((acc, domain) => { acc[domain] = type === "tv" ? `${domain}/embed/tv?tmdb=${tmdb_id}&season=${season}&episode=${episode}` : `${domain}/embed/movie/${tmdb_id}`; return acc; }, {}); // Create an AbortController to signal cancellation to other tasks. const controller = new AbortController(); const signal = controller.signal; try { const promises = Object.entries(urls).map(([domain, url]) => limit(() => { // Pass the signal to each scrapeProvider task. return scrapeProvider(domain, url, signal); }) ); // Promise.any waits for the first promise to be fulfilled (resolve). const firstSuccessfulResult = await Promise.any(promises); // As soon as we have a winner, abort all other ongoing scrape tasks. console.log(`\nSuccess from [${firstSuccessfulResult.source_domain}]. Aborting other scrapers.`); controller.abort(); // The response structure is simplified to show the single successful result. const response = { success: true, result: firstSuccessfulResult }; cache.set(cacheKey, response); res.json(response); } catch (err) { // This block is reached if ALL promises are rejected. if (err instanceof AggregateError) { console.error("All providers failed to find a link."); res.status(404).json({ success: false, error: "Could not find the video from any provider.", }); } else { // Handle other unexpected errors. console.error("An unexpected server error occurred:", err); res.status(500).json({ success: false, error: "Unexpected server error", }); } } }); // The startup and shutdown logic remains unchanged as it is already well-structured. (async () => { try { browserPool = new BrowserPool({ chromium: chromium, minSize: 1, maxSize: 5, maxUsage: 100, }); await browserPool.initialize(); console.log("Browser pool initialized successfully."); app.listen(PORT, () => console.log(`🚀 Universal Video Extractor running at http://localhost:${PORT}`)); } catch (error) { console.error("Failed to initialize browser pool:", error); process.exit(1); } })(); process.on("SIGINT", async () => { console.log("Shutting down gracefully..."); if (browserPool) await browserPool.shutdown(); process.exit(0); }); process.on("SIGTERM", async () => { console.log("Shutting down gracefully..."); if (browserPool) await browserPool.shutdown(); process.exit(0); });