CoolFace
Apppublic

Hunterdark99/PBstremio-addon

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
addon.js2478 linesDownload Raw Back to root
1const { addonBuilder, getRouter } = require("stremio-addon-sdk");2const express = require("express");3const fetch = require("node-fetch");4const cheerio = require("cheerio");5const { HttpsProxyAgent } = require("https-proxy-agent");6 7const PORT = process.env.PORT || 7860;8const BASE_URL = process.env.PB_BASE_URL || "https://pimpbunny.com";9const PUBLIC_BASE_URL = process.env.SPACE_URL || process.env.PUBLIC_URL || "";10 11const ENABLE_BROWSER_1080P = process.env.ENABLE_BROWSER_1080P === "1";12 13const BROWSER_1080P_TIMEOUT_MS = Number(process.env.BROWSER_1080P_TIMEOUT_MS || 12000);14const BROWSER_1080P_CACHE_MS = Number(process.env.BROWSER_1080P_CACHE_MS || 180 * 1000);15const BROWSER_IDLE_TTL_MS = Number(process.env.BROWSER_IDLE_TTL_MS || 45 * 1000);16const BROWSER_MIN_INTERVAL_MS = Number(process.env.BROWSER_MIN_INTERVAL_MS || 8000);17const BROWSER_MAX_JOBS_BEFORE_RESTART = Number(process.env.BROWSER_MAX_JOBS_BEFORE_RESTART || 12);18 19const MAX_RESOLVE_CANDIDATES = Number(process.env.MAX_RESOLVE_CANDIDATES || 2);20const RETRY_404 = process.env.RETRY_404 === "1";21const ENABLE_FRESH_REFETCH = process.env.ENABLE_FRESH_REFETCH === "1";22const ENABLE_ST_DIRECT_1080P = process.env.ENABLE_ST_DIRECT_1080P === "1";23 24const browser1080pCache = new Map();25 26let sharedBrowser = null;27let sharedBrowserLaunchPromise = null;28let browserIdleTimer = null;29let browserJobQueue = Promise.resolve();30let browserLastRunAt = 0;31let browserJobsSinceRestart = 0;32 33const GENRE_TAG_SLUGS = {34  "OnlyFans":    ["onlyfans"],35  "Amateur":     ["amateur"],36  "Milf":        ["milf"],37  "Teen":        ["teen"],38  "Anal":        ["anal"],39  "Blowjob":     ["blowjob"],40  "Lesbian":     ["lesbian"],41  "Interracial": ["interracial"],42  "Solo":        ["solo"],43  "BDSM":        ["bdsm"],44};45 46const PROXY_HOST = process.env.OUTBOUND_PROXY_HOST || "";47const PROXY_PORT_ENV = process.env.OUTBOUND_PROXY_PORT || "";48const PROXY_USER = process.env.OUTBOUND_PROXY_USERNAME || "";49const PROXY_PASS = process.env.OUTBOUND_PROXY_PASSWORD || "";50const PROXY_URL = process.env.OUTBOUND_PROXY_URL || (PROXY_HOST && PROXY_PORT_ENV51  ? `http://${PROXY_USER ? encodeURIComponent(PROXY_USER) : ""}${PROXY_PASS ? ':' + encodeURIComponent(PROXY_PASS) : ""}${PROXY_USER || PROXY_PASS ? '@' : ''}${PROXY_HOST}:${PROXY_PORT_ENV}`52  : "");53const proxyAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : null;54 55// TS/AAC/M4S segments are NOT proxied (bandwidth). Everything else goes through proxy.56const SEGMENT_RE = /\.(ts|aac|m4s|woff2)(\?|$)/i;57 58/**59 * Central fetch wrapper.60 * useProxy=true  → route through proxyAgent (HTML pages, images, API calls, get_file)61 * useProxy=false → direct (only raw media segments)62 */63async function doFetch(url, opts = {}, useProxy = true) {64  const options = { redirect: "follow", ...opts };65  if (useProxy && proxyAgent) options.agent = proxyAgent;66  return fetch(url, options);67}68 69const manifest = {70  id: "community.pimpbunny.vod",71  version: "1.0.0",72  name: "[18+] PimpBunny",73  description: "18+ adult videos scraped from pimpbunny.com.",74  logo: "https://pimpbunny.com/favicon.ico",75  types: ["movie"],76  resources: ["catalog", { name: "meta", types: ["movie"] }, { name: "stream", types: ["movie"] }],77  idPrefixes: ["pb:"],78  catalogs: [79    {80      type: "movie",81      id: "latest",82      name: "PB Latest Videos",83      extra: [84        {85          name: "genre",86          isRequired: false,87          options: ["OnlyFans", "Amateur", "Milf", "Teen", "Anal", "Blowjob", "Lesbian", "Interracial", "Solo", "BDSM"]88        },89        { name: "skip", isRequired: false },90        { name: "search", isRequired: false }91      ],92      behaviorHints: { adult: true, configurable: false, configurationRequired: false }93    }94  ]95};96 97const builder = new addonBuilder(manifest);98const metaCache = new Map();99 100 101setInterval(() => {102  const cutoff = Date.now() - 60 * 60 * 1000;103  for (const [key, val] of metaCache.entries()) {104    if (val.updatedAt < cutoff) metaCache.delete(key);105  }106}, 15 * 60 * 1000);107 108const HEADERS = {109  "User-Agent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36",110  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",111  "Accept-Language": "en-US,en;q=0.9",112  "Cache-Control": "no-cache",113  "Pragma": "no-cache",114  "Upgrade-Insecure-Requests": "1",115  "Referer": BASE_URL + "/"116};117 118// Headers for fetching video files — matches a browser watching the video119const VIDEO_HEADERS = {120  "User-Agent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36",121  "Accept": "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5",122  "Accept-Language": "en-US,en;q=0.9",123  "Referer": BASE_URL + "/",124  "Origin": BASE_URL,125};126 127function absoluteUrl(url) {128  if (!url) return null;129  try { return new URL(url, BASE_URL).toString(); } catch { return null; }130}131 132async function fetchHtml(url) {133  console.log(`[fetchHtml] GET ${url} (proxy=${!!proxyAgent})`);134  const res = await doFetch(url, { headers: HEADERS }, true);135  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);136  return await res.text();137}138 139function makeIdFromPath(pathname) {140  return `pb:${pathname.replace(/^\/+|\/+$/g, "")}`;141}142 143function decodeId(id) {144  return id.replace(/^pb:/, "");145}146 147// ── Taxonomy prefix probe ─────────────────────────────────────────────────────148let _taxonomyCache = null;149async function getTaxonomyPrefix(testSlug) {150  if (_taxonomyCache) return _taxonomyCache.prefix;151  const prefixes = ["tag", "category", "tags", "categories", "genre", "niche"];152  for (const prefix of prefixes) {153    const url = `${BASE_URL}/${prefix}/${testSlug}/`;154    try {155      const res = await doFetch(url, { headers: HEADERS }, true);156      console.log(`[taxonomy] probe /${prefix}/${testSlug}/ → HTTP ${res.status}`);157      if (res.ok) {158        console.log(`[taxonomy] ✅ resolved prefix: /${prefix}/`);159        _taxonomyCache = { prefix };160        return prefix;161      }162    } catch (e) {163      console.log(`[taxonomy] probe error /${prefix}/${testSlug}/: ${e.message}`);164    }165  }166  console.warn(`[taxonomy] ⚠️ all prefixes failed for "${testSlug}", defaulting to "tag"`);167  _taxonomyCache = { prefix: "tag" };168  return "tag";169}170 171// ── Post card extractor ───────────────────────────────────────────────────────172function extractPostCards(html, baseUrl) {173  const $ = cheerio.load(html);174  const siteBase = baseUrl || BASE_URL;175  const siteHostname = (() => { try { return new URL(siteBase).hostname; } catch { return ""; } })();176 177  const results = [];178  const seenHref = new Set();179 180  $("a[href]").each((_, el) => {181    const a = $(el);182    const rawHref = a.attr("href");183    if (!rawHref) return;184 185    let href;186    try { href = new URL(rawHref, siteBase).toString(); } catch { return; }187 188    let u;189    try {190      u = new URL(href);191      if (u.hostname !== siteHostname) return;192    } catch { return; }193 194    if (/^\/(page|tag|tags|category|categories|author|search|wp-|feed|#|genre|niche)/i.test(u.pathname)) return;195    if (/\/page\/\d+/i.test(u.pathname)) return;196    if (u.pathname === "/" || u.pathname === "") return;197    if (!/-/.test(u.pathname)) return;198    const segments = u.pathname.replace(/^\/|\/$/g, "").split("/");199    if (segments.length === 1 && segments[0].length < 5) return;200 201    if (seenHref.has(href)) return;202    seenHref.add(href);203 204    const container = a.closest(205      "article, .post, [class*='post'], [class*='item'], [class*='card'], [class*='thumb'], [class*='entry'], li, div"206    );207 208    const title =209      a.attr("title") ||210      container.find("h1, h2, h3, h4, [class*='title'], [class*='name']").first().text().trim() ||211      a.find("h1, h2, h3, h4, [class*='title']").first().text().trim() ||212      a.text().trim() ||213      segments[segments.length - 1].replace(/-/g, " ");214 215    if (!title || title.length < 3) return;216    if (/^(home|about|contact|blog|videos|models|categories|tags|search|login|register|privacy|terms|dmca|sitemap)$/i.test(title.trim())) return;217 218    const imgNode = container.find("img").first();219    const rawImg = absoluteUrl(220      imgNode.attr("data-src") ||221      imgNode.attr("data-lazy-src") ||222      imgNode.attr("data-original") ||223      imgNode.attr("data-thumb") ||224      (() => { const ss = imgNode.attr("srcset"); if (!ss) return null; const m = ss.match(/https?:\/\/[^ ,]+/); return m ? m[0] : null; })() ||225      imgNode.attr("src")226    );227 228    const imgOk = rawImg && !/(placeholder|avatar|logo|icon|blank|spacer|pixel|\.gif)/i.test(rawImg);229    const img = imgOk230      ? (PUBLIC_BASE_URL ? `${PUBLIC_BASE_URL}/imgproxy?url=${encodeURIComponent(rawImg)}` : rawImg)231      : undefined;232 233    const description = container234      .find("[class*='desc'], [class*='excerpt'], [class*='summary'], p")235      .first().text().trim().substring(0, 200);236 237    const date =238      container.find("time").attr("datetime") ||239      container.find("[class*='date'], [class*='time']").first().text().trim();240 241    results.push({242      id: makeIdFromPath(u.pathname),243      type: "movie",244      name: title,245      poster: img,246      posterShape: "landscape",247      background: img,248      description: [date, description].filter(Boolean).join(" • "),249      website: href250    });251  });252 253  const seen = new Set();254  const deduped = results.filter(x => x.id && !seen.has(x.id) && (seen.add(x.id), true));255  console.log(`[catalog] extractPostCards found ${deduped.length} items`);256  return deduped.slice(0, 24);257}258 259// ── Catalog fetcher ───────────────────────────────────────────────────────────260async function fetchCatalogPage(catalogId, skip = 0, search = "", genre = "") {261  const page = Math.floor((Number(skip) || 0) / 24) + 1;262 263  if (search) {264    const searchUrls = [265      `${BASE_URL}/?s=${encodeURIComponent(search)}${page > 1 ? `&paged=${page}` : ""}`,266      `${BASE_URL}/search/${encodeURIComponent(search)}${page > 1 ? `/page/${page}/` : "/"}`,267    ];268    for (const url of searchUrls) {269      try {270        const html = await fetchHtml(url);271        const metas = extractPostCards(html);272        if (metas.length > 0) return metas;273      } catch (err) {274        console.warn(`Search fetch failed: ${url} -> ${err.message}`);275      }276    }277    throw new Error("No search results could be fetched");278  }279 280  if (genre && GENRE_TAG_SLUGS[genre]) {281    const prefix = await getTaxonomyPrefix(GENRE_TAG_SLUGS[genre][0]);282    const allMetas = [];283    const seen = new Set();284 285    for (const slug of GENRE_TAG_SLUGS[genre]) {286      const urls = [287        `${BASE_URL}/${prefix}/${slug}/${page > 1 ? `page/${page}/` : ""}`,288        `${BASE_URL}/${prefix}/${slug}${page > 1 ? `/?paged=${page}` : "/"}`,289      ];290      for (const genreUrl of urls) {291        try {292          const html = await fetchHtml(genreUrl);293          const metas = extractPostCards(html);294          for (const m of metas) {295            if (!seen.has(m.id)) { seen.add(m.id); allMetas.push(m); }296          }297          if (allMetas.length > 0) break;298        } catch (err) {299          console.warn(`Genre "${slug}" @ ${genreUrl} failed: ${err.message}`);300        }301      }302    }303    if (allMetas.length > 0) return allMetas;304  }305 306  // Default: try several home/listing URL patterns307  const candidates = [308    `${BASE_URL}/${page > 1 ? `?paged=${page}` : ""}`,309    `${BASE_URL}/${page > 1 ? `page/${page}` : ""}`,310    `${BASE_URL}/videos${page > 1 ? `?paged=${page}` : ""}`,311    `${BASE_URL}/videos/${page > 1 ? `page/${page}/` : ""}`,312  ];313  let lastError = null;314  for (const url of candidates) {315    try {316      const html = await fetchHtml(url);317      const metas = extractPostCards(html);318      if (metas.length > 0) return metas;319    } catch (err) {320      lastError = err;321      console.warn(`Catalog candidate failed: ${url} -> ${err.message}`);322    }323  }324  throw lastError || new Error("No catalog pages could be fetched");325}326 327function extractVideoIdFromHtml(html) {328  const patterns = [329    /\/embed\/(\d+)/i,330    /video_id["']?\s*[:=]\s*["']?(\d+)/i,331    /videoId["']?\s*[:=]\s*["']?(\d+)/i,332    /video-id=["']?(\d+)/i,333    /\/videos_screenshots\/\d+\/(\d+)\//i,334    /\/get_file\/\d+\/[^/]+\/\d+\/(\d+)\//i335  ];336 337  for (const re of patterns) {338    const m = html.match(re);339    if (m?.[1]) return m[1];340  }341 342  return null;343}344 345function urlBelongsToVideo(url, videoId) {346  if (!videoId) return true;347 348  let decoded = String(url || "");349  try {350    decoded = decodeURIComponent(decoded);351  } catch {}352 353  return (354    decoded.includes(`/${videoId}/`) ||355    decoded.includes(`_${videoId}_`) ||356    decoded.includes(`/embed/${videoId}`) ||357    decoded.includes(`${videoId}_pb_`) ||358    decoded.includes(`${videoId}_preview`) ||359    decoded.includes(`/${videoId}_`)360  );361}362 363function isPreviewOrThumbMp4(url) {364  let decoded = String(url || "");365  try {366    decoded = decodeURIComponent(decoded);367  } catch {}368 369  return /(preview_pb|_preview\.mp4|videos_screenshots|thumb|poster|listing|webp)/i.test(decoded);370}371 372function decodeEscapedMediaString(value) {373  return String(value || "")374    .replace(/&amp;/g, "&")375    .replace(/\\u0026/gi, "&")376    .replace(/\\u003d/gi, "=")377    .replace(/\\u003f/gi, "?")378    .replace(/\\u002f/gi, "/")379    .replace(/\\\//g, "/")380    .replace(/\\\\\//g, "/")381    .replace(/\\"/g, '"')382    .replace(/&#x2F;/g, "/")383    .replace(/&#47;/g, "/")384    .trim();385}386 387function unwrapKvsFunctionUrl(value) {388  let v = decodeEscapedMediaString(value);389 390  // KVS player format:391  // function/0/https://site/get_file/.../video_1080p.mp4/392  const m = v.match(/^function\/\d+\/(https?:\/\/.+)$/i);393  if (m) v = m[1];394 395  v = v.replace(/^[`'"]+|[`'"]+$/g, "");396  v = v.replace(/[),;]+$/g, "");397 398  return v;399}400 401function getQualityFromUrlOrText(url, text = "") {402  const s = `${decodeEscapedMediaString(url)} ${decodeEscapedMediaString(text)}`;403 404  if (/_1080p\.mp4/i.test(s) || /\b1080p\b/i.test(s)) return "1080p";405  if (/_720p\.mp4/i.test(s) || /\b720p\b/i.test(s)) return "720p";406  if (/_480p\.mp4/i.test(s) || /\b480p\b/i.test(s)) return "480p";407  if (/_360p\.mp4/i.test(s) || /\b360p\b/i.test(s)) return "360p";408 409  if (/\.mp4\/?(?:[?#]|$)/i.test(s)) return "480p";410 411  return "HD";412}413 414function qualityRank(q) {415  return {416    "1080p": 0,417    "720p": 1,418    "480p": 2,419    "360p": 3,420    "HD": 4421  }[q] ?? 99;422}423 424function extractKvsPlayerSources(html, videoId = null) {425  const text = decodeEscapedMediaString(html);426  const out = [];427  const seen = new Set();428 429  const urlRe = /\b(video_url|video_alt_url\d*)\s*:\s*(['"`])((?:\\.|(?!\2).)*?)\2/gi;430 431  for (const m of text.matchAll(urlRe)) {432    const key = m[1];433    const rawValue = m[3];434    const unwrapped = unwrapKvsFunctionUrl(rawValue);435 436    if (!/^https?:\/\//i.test(unwrapped)) continue;437    if (!/\/get_file\//i.test(unwrapped)) continue;438    if (!/\.mp4\/?(?:[?#]|$)/i.test(unwrapped)) continue;439    if (isPreviewOrThumbMp4(unwrapped)) continue;440    if (!urlBelongsToVideo(unwrapped, videoId)) continue;441 442    const textKey = `${key}_text`;443    const textRe = new RegExp(`\\b${textKey}\\s*:\\s*(['"\`])((?:\\\\.|(?!\\1).)*?)\\1`, "i");444    const textMatch = text.match(textRe);445    const qualityText = textMatch ? textMatch[2] : "";446 447    const quality = getQualityFromUrlOrText(unwrapped, qualityText);448    const dedupeKey = `${quality}:${unwrapped}`;449 450    if (seen.has(dedupeKey)) continue;451    seen.add(dedupeKey);452 453    out.push({454      key,455      quality,456      url: unwrapped457    });458  }459 460  out.sort((a, b) => qualityRank(a.quality) - qualityRank(b.quality));461 462  return out;463}464 465function getGetFileHash(url) {466  const m = String(url || "").match(/\/get_file\/\d+\/([^/]+)\//i);467  return m ? m[1] : "";468}469 470function collectAllGetFileCandidates(text, videoId = null) {471  const normalized = decodeEscapedMediaString(text);472  const out = [];473  const seen = new Set();474 475  const patterns = [476    /https?:\/\/[^"'\\\s<>]+\/get_file\/[^"'\\\s<>]+\.mp4\/?(?:[?#][^"'\\\s<>]*)?/gi,477    /function\/\d+\/https?:\/\/[^"'\\\s<>]+\/get_file\/[^"'\\\s<>]+\.mp4\/?/gi478  ];479 480  const add = (raw, source) => {481    let u = unwrapKvsFunctionUrl(raw);482    u = decodeEscapedMediaString(u).replace(/[),;'"<>]+$/g, "");483 484    if (!/^https?:\/\//i.test(u)) return;485    if (!/\/get_file\//i.test(u)) return;486    if (!/\.mp4\/?(?:[?#]|$)/i.test(u)) return;487    if (isPreviewOrThumbMp4(u)) return;488    if (!urlBelongsToVideo(u, videoId)) return;489 490    const quality = getQualityFromUrlOrText(u);491    const hash = getGetFileHash(u);492    const key = `${quality}:${hash}:${u}`;493 494    if (seen.has(key)) return;495    seen.add(key);496 497    out.push({ quality, hash, source, url: u });498  };499 500  for (const re of patterns) {501    for (const m of normalized.matchAll(re)) {502      add(m[0], "raw-html");503    }504  }505 506  for (const src of extractKvsPlayerSources(normalized, videoId)) {507    add(src.url, `kvs:${src.key}`);508  }509 510  out.sort((a, b) => {511    const q = qualityRank(a.quality) - qualityRank(b.quality);512    if (q !== 0) return q;513    return a.hash.localeCompare(b.hash);514  });515 516  return out;517}518 519async function collectCandidatesFromPageResources(html, pageUrl, videoId, cookieStr) {520  const $ = cheerio.load(html);521  const resources = new Set();522 523  const fullScan = process.env.FULL_RESOURCE_SCAN === "1";524 525  const addResource = (raw) => {526    if (!raw) return;527 528    try {529      const abs = new URL(raw, pageUrl).toString();530      const u = new URL(abs);531 532      if (u.origin !== BASE_URL) return;533 534      // Default: only scan resources that have actually been useful.535      if (/\/embed\/\d+/i.test(u.pathname)) {536        resources.add(abs);537        return;538      }539 540      if (/\/player\/kt_player\.js/i.test(u.pathname)) {541        resources.add(abs);542        return;543      }544 545      // Optional debug mode only.546      if (fullScan && /\.js(?:$|\?)/i.test(u.pathname + u.search)) {547        resources.add(abs);548      }549    } catch {}550  };551 552  $("script[src], iframe[src]").each((_, el) => {553    addResource($(el).attr("src"));554  });555 556  if (videoId) {557    resources.add(`${BASE_URL}/embed/${videoId}`);558  }559 560  const all = [];561 562  for (const resourceUrl of resources) {563    try {564      console.log(`[resource-scan] fetching ${resourceUrl}`);565 566      const res = await doFetch(resourceUrl, {567        headers: {568          ...HEADERS,569          Cookie: cookieStr,570          Referer: pageUrl,571          Origin: BASE_URL572        }573      }, true);574 575      console.log(`[resource-scan] ${resourceUrl} -> HTTP ${res.status}`);576 577      if (!res.ok) continue;578 579      const body = await res.text();580      const candidates = collectAllGetFileCandidates(body, videoId);581 582      for (const c of candidates) {583        all.push({584          ...c,585          source: `resource:${resourceUrl}`586        });587 588        console.log(589          `[resource-scan] candidate ${c.quality} hash=${c.hash} source=${resourceUrl} url=${c.url}`590        );591      }592    } catch (e) {593      console.log(`[resource-scan] error ${resourceUrl}: ${e.message}`);594    }595  }596 597  const seen = new Set();598 599  return all.filter(c => {600    const key = `${c.quality}:${c.hash}:${c.url}`;601    if (seen.has(key)) return false;602    seen.add(key);603    return true;604  });605}606 607// ── Video URL extraction ──────────────────────────────────────────────────────608function findVideoUrls(html, forcedVideoId = null) {609  const found = new Set();610  const log = (msg) => console.log(`[extract] ${msg}`);611 612  const currentVideoId = forcedVideoId || extractVideoIdFromHtml(html);613  log(`currentVideoId=${currentVideoId || "unknown"}`);614 615  const normalizeCandidateUrl = (u) => {616    return String(u || "")617      .replace(/&amp;/g, "&")618      .replace(/\\u0026/gi, "&")619      .replace(/\\u003d/gi, "=")620      .replace(/\\u003f/gi, "?")621      .replace(/\\u002f/gi, "/")622      .replace(/\\\//g, "/")623      .replace(/\\\\\//g, "/")624      .trim()625      .replace(/[),;]+$/g, "");626  };627 628  const add = (u, why = "") => {629    if (!u) return;630 631    let v = normalizeCandidateUrl(u);632 633    if (v.startsWith("//")) {634      v = "https:" + v;635    }636 637    if (!v) return;638 639    // Critical: ignore related videos / random page previews.640    if (!urlBelongsToVideo(v, currentVideoId)) return;641 642    if (found.has(v)) return;643 644    log(`candidate (${why}) ${v}`);645    found.add(v);646  };647 648  const scanText = (text, label) => {649    if (!text) return;650 651    const normalizedText = normalizeCandidateUrl(text);652 653    normalizedText654      .match(/(?:https?:)?\/\/[^"'\\\s<>]+\/remote_control\.php\?[^"'\\\s<>]+/gi)655      ?.forEach(u => add(u, `${label}:remote_control`));656 657    normalizedText658      .match(/https?:\/\/[^"'\\\s<>]+\.(?:m3u8|mp4)(?:[?#][^"'\\\s<>]*)?/gi)659      ?.forEach(u => add(u, `${label}:direct`));660 661    normalizedText662      .match(/<iframe[^>]+src=["']([^"']+)["']/gi)663      ?.forEach(tag => {664        const m = tag.match(/src=["']([^"']+)["']/i);665        if (m) add(absoluteUrl(m[1]), `${label}:iframe`);666      });667  };668 669  log(`html length=${html.length}`);670 671  const $ = cheerio.load(html);672 673  scanText(html, "full-html");674 675  $("script").each((i, el) => {676    const text = $(el).html() || $(el).text() || "";677    if (text.length < 50) return;678    scanText(text, `script[${i}]`);679  });680 681  $("[src], [data-src], [data-url], [data-file], iframe").each((_, el) => {682    const tag = el.tagName.toLowerCase();683    const attrs = el.attribs || {};684 685    for (const k of ["src", "data-src", "data-url", "data-file"]) {686      if (attrs[k]) add(attrs[k], `${tag}:${k}`);687    }688  });689 690  log(`total candidates=${found.size}`);691 692  const remoteControl = [];693  const getFileMp4 = [];694  const directM3u8 = [];695  const embeds = [];696 697  for (const u of found) {698    let decoded = u;699    try {700      decoded = decodeURIComponent(u);701    } catch {}702 703    const isRemoteControlMp4 =704      /\/remote_control\.php\?/i.test(u) &&705      /(?:[?&])file=/i.test(u) &&706      /\.mp4/i.test(decoded);707 708    if (isRemoteControlMp4) {709      remoteControl.push(u);710    } else if (/\/get_file\//i.test(u) && /\.mp4(?:[?#]|$)/i.test(u)) {711      // Keep only real current-video files, not previews.712      if (!isPreviewOrThumbMp4(u)) {713        getFileMp4.push(u);714      }715    } else if (/\.m3u8(?:[?#]|$)/i.test(u)) {716      directM3u8.push(u);717    } else if (/\/embed\/\d+/i.test(u)) {718      embeds.push(u);719    }720  }721 722  log(`classified: remote=${remoteControl.length}, get_file=${getFileMp4.length}, m3u8=${directM3u8.length}, embeds=${embeds.length}`);723 724  const sortByQuality = (urls) => {725    const qualityOrder = ["_1080p", "_720p", "_480p", "_360p"];726 727    const sorted = qualityOrder728      .map(q => urls.find(u => {729        try {730          return decodeURIComponent(u).includes(q);731        } catch {732          return u.includes(q);733        }734      }))735      .filter(Boolean);736 737    for (const u of urls) {738      if (!sorted.includes(u)) sorted.push(u);739    }740 741    return sorted;742  };743 744  if (remoteControl.length) {745  const sorted = sortByQuality(remoteControl);746  log(`✅ REMOTE list: ${sorted.join(", ")}`);747  return sorted;748}749 750  // Critical: prefer embed over get_file.751  // get_file URLs are showing as 404, but the embed/player may generate remote_control.php.752  if (embeds.length) {753    const best = embeds.find(u => /\/embed\/\d+/i.test(u)) || embeds[0];754    log(`✅ embed: ${best}`);755    return [best];756  }757 758  if (directM3u8.length) {759    const best = directM3u8.sort((a, b) => b.length - a.length)[0];760    log(`✅ M3U8: ${best}`);761    return [best];762  }763 764  // Do NOT return get_file as a stream candidate anymore.765  // Keep this only as a debug log.766  if (getFileMp4.length) {767  log(`⚠️ ignoring ${getFileMp4.length} get_file URL(s); they are not stable playable streams`);768}769 770  log(`❌ No playable media found`);771  return [];772}773 774function getVideoIdFromAnyUrl(url) {775  const s = String(url || "");776 777  const patterns = [778    /\/embed\/(\d+)/i,779    /\/videos_screenshots\/\d+\/(\d+)\//i,780    /\/get_file\/\d+\/[^/]+\/\d+\/(\d+)\//i,781    /file=%2Fvideos%2F\d+%2F(\d+)%2F/i,782    /file=\/videos\/\d+\/(\d+)\//i,783    /video_id=(\d+)/i784  ];785 786  for (const re of patterns) {787    const m = s.match(re);788    if (m?.[1]) return m[1];789  }790 791  return null;792}793 794function extractRemoteControlUrlsFromText(text, videoId = null) {795  const out = new Set();796 797  const normalize = (s) =>798    String(s || "")799      .replace(/&amp;/g, "&")800      .replace(/\\u0026/gi, "&")801      .replace(/\\u003d/gi, "=")802      .replace(/\\u003f/gi, "?")803      .replace(/\\u002f/gi, "/")804      .replace(/\\\//g, "/")805      .replace(/\\\\\//g, "/")806      .replace(/\\"/g, '"')807      .trim();808 809  const t = normalize(text);810 811  const patterns = [812    /https?:\/\/[^"'\\\s<>]+\/remote_control\.php\?[^"'\\\s<>]+/gi,813    /\/\/[^"'\\\s<>]+\/remote_control\.php\?[^"'\\\s<>]+/gi814  ];815 816  for (const re of patterns) {817    for (const m of t.matchAll(re)) {818      let u = normalize(m[0]).replace(/[),;]+$/g, "");819 820      if (u.startsWith("//")) {821        u = "https:" + u;822      }823 824      let decoded = u;825      try {826        decoded = decodeURIComponent(u);827      } catch {}828 829      if (videoId && !decoded.includes(`/${videoId}/`) && !decoded.includes(`%2F${videoId}%2F`)) {830        continue;831      }832 833      if (/\/remote_control\.php\?/i.test(u) && /\.mp4/i.test(decoded)) {834        out.add(u);835      }836    }837  }838 839  return [...out];840}841 842async function resolveEmbedToRemoteControlUrls(embedUrl, pageUrl, videoId) {843  try {844    const fullEmbedUrl = absoluteUrl(embedUrl);845    if (!fullEmbedUrl) return [];846 847    console.log(`[embed-resolve] fetching embed: ${fullEmbedUrl}`);848 849    const embedRes = await doFetch(fullEmbedUrl, {850      headers: {851        ...HEADERS,852        Referer: pageUrl,853        Origin: BASE_URL854      }855    }, true);856 857    if (!embedRes.ok) {858      console.log(`[embed-resolve] embed HTTP ${embedRes.status}`);859      return [];860    }861 862    const embedHtml = await embedRes.text();863 864    let found = extractRemoteControlUrlsFromText(embedHtml, videoId);865 866    if (found.length) {867      console.log(`[embed-resolve] ✅ remote_control in embed html: ${found.length}`);868      return found;869    }870 871    const $ = cheerio.load(embedHtml);872 873    const inlineScripts = [];874    $("script:not([src])").each((_, el) => {875      const txt = $(el).html() || "";876      if (txt.trim()) inlineScripts.push(txt);877    });878 879    for (let i = 0; i < inlineScripts.length; i++) {880      found = extractRemoteControlUrlsFromText(inlineScripts[i], videoId);881      if (found.length) {882        console.log(`[embed-resolve] ✅ remote_control in inline script ${i}: ${found.length}`);883        return found;884      }885    }886 887    const externalScripts = [];888    $("script[src]").each((_, el) => {889      const src = $(el).attr("src");890      if (!src) return;891 892      try {893        const abs = new URL(src, fullEmbedUrl).toString();894        if (!externalScripts.includes(abs)) externalScripts.push(abs);895      } catch {}896    });897 898    for (const scriptUrl of externalScripts) {899      try {900        console.log(`[embed-resolve] scanning script: ${scriptUrl}`);901 902        const sres = await doFetch(scriptUrl, {903          headers: {904            ...HEADERS,905            Referer: fullEmbedUrl,906            Origin: BASE_URL907          }908        }, true);909 910        if (!sres.ok) continue;911 912        const js = await sres.text();913        found = extractRemoteControlUrlsFromText(js, videoId);914 915        if (found.length) {916          console.log(`[embed-resolve] ✅ remote_control in external script: ${found.length}`);917          return found;918        }919      } catch (e) {920        console.log(`[embed-resolve] script error ${scriptUrl}: ${e.message}`);921      }922    }923 924    console.log(`[embed-resolve] ❌ no remote_control found in embed resources`);925    return [];926  } catch (err) {927    console.log(`[embed-resolve] error: ${err.message}`);928    return [];929  }930}931 932async function resolveGetFileToPlayableUrl(getFileUrl, pageUrl) {933  try {934    console.log(`[resolve] trying get_file redirect: ${getFileUrl}`);935 936    const headers = {937      ...VIDEO_HEADERS,938      "Referer": pageUrl,939      "Origin": BASE_URL,940      "Accept": "*/*"941    };942 943    // Do NOT use Range here. We want the redirect target, not media bytes.944    const res = await doFetch(getFileUrl, {945      headers,946      redirect: "manual"947    }, true);948 949    const location = res.headers.get("location");950 951    if (location) {952      const resolved = new URL(location, getFileUrl).toString();953      console.log(`[resolve] location: ${resolved}`);954 955      if (/\/remote_control\.php\?/i.test(resolved) || /\.mp4(?:[?#]|$)/i.test(resolved)) {956        return resolved;957      }958    }959 960    const contentType = res.headers.get("content-type") || "";961 962    if (res.ok && /video|octet-stream/i.test(contentType)) {963      console.log(`[resolve] get_file itself appears playable`);964      return getFileUrl;965    }966 967    const body = await res.text().catch(() => "");968    console.log(`[resolve] failed status=${res.status} body=${body.substring(0, 200)}`);969 970    return null;971  } catch (err) {972    console.log(`[resolve] error: ${err.message}`);973    return null;974  }975}976 977// ── Embed resolver ────────────────────────────────────────────────────────────978async function tryEmbedApiEndpoints(embedPageUrl, token, scanBlock) {979  const embedOrigin = (() => { try { return new URL(embedPageUrl).origin; } catch { return ""; } })();980  const endpoints = [981    `${embedOrigin}/api/source`, `${embedOrigin}/api/stream`, `${embedOrigin}/api/video`,982    `${embedOrigin}/api/player`, `${embedOrigin}/api/get`, `${embedOrigin}/source`, `${embedOrigin}/stream`,983  ];984  const bodies = [985    token ? JSON.stringify({ token }) : null,986    token ? `token=${encodeURIComponent(token)}` : null,987  ].filter(Boolean);988 989  for (const endpoint of endpoints) {990    for (const body of bodies) {991      try {992        const isJson = body.startsWith("{");993        const res = await doFetch(endpoint, {994          method: "POST",995          headers: { ...HEADERS, Referer: embedPageUrl, Origin: embedOrigin, "Content-Type": isJson ? "application/json" : "application/x-www-form-urlencoded" },996          body,997        }, true);998        if (!res.ok) continue;999        const found = scanBlock(await res.text(), `api-endpoint:${endpoint}`);1000        if (found) return found;1001      } catch (e) { console.log(`[embed] api error ${endpoint}: ${e.message}`); }1002    }1003  }1004  return null;1005}1006 1007async function resolveEmbedUrl(embedUrl) {1008  try {1009    const fullUrl = absoluteUrl(embedUrl);1010    if (!fullUrl) return null;1011    console.log(`[embed] resolving: ${fullUrl}`);1012 1013    const normalize = (s) =>1014      String(s || "")1015        .replace(/&amp;/g, "&").replace(/\\\\\//g, "/").replace(/\\\\/g, "\\")1016        .replace(/\\"/g, '"').replace(/\\u002F/gi, "/").replace(/\\u003A/gi, ":")1017        .replace(/&#x2F;/g, "/").replace(/&#47;/g, "/").trim();1018 1019    const isValidMedia = (u) =>1020      u && /(?:master\.txt|\.m3u8|\.mp4)(?:\?|$)/i.test(u) && !/\.php(?:\?|$)/i.test(u);1021 1022    const pickUrl = (text, label) => {1023      if (!text) return null;1024      const t = normalize(text);1025      const patterns = [1026        /(?:file|src|url|source|hls|playlist)\s*:\s*["'`]([^"'`\s]{10,})["'`]/gi,1027        /["'](?:file|src|url|source|hls|playlist)["']\s*:\s*["'`]([^"'`\s]{10,})["'`]/gi,1028        /https?:\/\/[^\s"'<>\\]+(?:master\.txt|\.m3u8|\.mp4)(?:\?[^\s"'<>\\]*)?/gi,1029      ];1030      for (const re of patterns) {1031        for (const m of [...t.matchAll(re)]) {1032          const raw = normalize(m[1] || m[0]);1033          if (isValidMedia(raw)) { console.log(`[embed] pickUrl (${label}): ${raw}`); return raw; }1034        }1035      }1036      return null;1037    };1038 1039    const scanBlock = (text, label) => {1040      if (!text) return null;1041      const direct = pickUrl(text, label);1042      if (direct) return direct;1043      const t = normalize(text);1044      const blocks = [1045        ...t.matchAll(/sources\s*:\s*\[([\s\S]{0,20000}?)\]/gi),1046        ...t.matchAll(/playlist\s*:\s*\[([\s\S]{0,20000}?)\]/gi),1047        ...t.matchAll(/setup\s*\(\s*\{([\s\S]{0,20000}?)\}\s*\)/gi),1048        ...t.matchAll(/jwplayer\s*\([^)]*\)\s*\.setup\s*\(\s*\{([\s\S]{0,20000}?)\}\s*\)/gi),1049        ...t.matchAll(/new\s+Player\s*\(\s*\{([\s\S]{0,20000}?)\}\s*\)/gi),1050        ...t.matchAll(/playerConfig\s*=\s*\{([\s\S]{0,20000}?)\}/gi),1051        ...t.matchAll(/var\s+\w+\s*=\s*\{([\s\S]{0,20000}?)\}/gi),1052      ];1053      for (const m of blocks) {1054        const found = pickUrl(m[1], `${label}:block`);1055        if (found) return found;1056      }1057      return null;1058    };1059 1060    const tokenMatch = fullUrl.match(/[?&]token=([^&\s]+)/i);1061    const tokenVal = tokenMatch ? tokenMatch[1] : null;1062    if (tokenVal) {1063      try {1064        const decoded = Buffer.from(decodeURIComponent(tokenVal), "base64").toString("utf8");1065        const found = scanBlock(decoded, "decoded token");1066        if (found) return found;1067      } catch (e) { console.log(`[embed] token decode error: ${e.message}`); }1068    }1069 1070    const idMatch = fullUrl.match(/[?&]id=(\d+)/i);1071    if (idMatch) {1072      try {1073        const res = await doFetch(`https://cdn.jwplayer.com/players/${idMatch[1]}-IDzF9Zmk.js`, { headers: HEADERS }, true);1074        if (res.ok) { const found = scanBlock(await res.text(), "jw-embed-js"); if (found) return found; }1075      } catch (e) { console.log(`[embed] JW error: ${e.message}`); }1076    }1077 1078    const res = await doFetch(fullUrl, { headers: { ...HEADERS, Referer: BASE_URL + "/", Origin: BASE_URL } }, true);1079    if (!res.ok) { console.log(`[embed] HTTP ${res.status}`); return null; }1080    const html = await res.text();1081 1082    if (html.trimStart().startsWith('#EXTM3U')) return fullUrl;1083 1084    const foundHtml = scanBlock(html, "raw html");1085    if (foundHtml) return foundHtml;1086 1087    const $ = cheerio.load(html);1088 1089    const fileMatch = html.match(/file\s*:\s*["'`]((?:\\.|[^"'`])+?)["'`]/i);1090    if (fileMatch) {1091      const raw = normalize(fileMatch[1]);1092      const embedOrigin = (() => { try { return new URL(fullUrl).origin; } catch { return ""; } })();1093      const resolved = raw.startsWith("http") ? raw : `${embedOrigin}${raw.startsWith("/") ? "" : "/"}${raw}`;1094      if (resolved.startsWith("http")) return resolved;1095    }1096 1097    for (const sel of ["video", "source", "[data-file]", "[data-src]", "[data-url]", "[data-hls]", "[data-m3u8]", "[data-playlist]", "[data-source]"]) {1098      let matched = null;1099      $(sel).each((_, el) => {1100        if (matched) return;1101        for (const a of ["data-file", "data-src", "data-url", "data-hls", "data-m3u8", "data-playlist", "data-source", "src"]) {1102          const v = $(el).attr(a);1103          const abs = v && absoluteUrl(v);1104          if (isValidMedia(abs)) { matched = abs; return; }1105          if (isValidMedia(v)) { matched = v; return; }1106        }1107      });1108      if (matched) return matched;1109    }1110 1111    const scriptBlocks = [];1112    $("script:not([src])").each((_, el) => { const t = $(el).html() || ""; if (t.trim()) scriptBlocks.push(t); });1113    for (let i = 0; i < scriptBlocks.length; i++) {1114      const found = scanBlock(scriptBlocks[i], `inline-script[${i}]`);1115      if (found) return found;1116    }1117 1118    const extScripts = [];1119    $("script[src]").each((_, el) => {1120      const src = $(el).attr("src");1121      if (!src) return;1122      try { const abs = new URL(src, fullUrl).toString(); if (!extScripts.includes(abs)) extScripts.push(abs); } catch {}1123    });1124    for (const scriptSrc of extScripts) {1125      try {1126        const sres = await doFetch(scriptSrc, { headers: { ...HEADERS, Referer: fullUrl } }, true);1127        if (!sres.ok) continue;1128        const found = scanBlock(await sres.text(), `ext-script:${scriptSrc}`);1129        if (found) return found;1130      } catch (e) { console.log(`[embed] ext script error: ${scriptSrc} -> ${e.message}`); }1131    }1132 1133    return await tryEmbedApiEndpoints(fullUrl, tokenVal, scanBlock);1134  } catch (err) {1135    console.error(`[embed] failed: ${err.message}`);1136    return null;1137  }1138}1139 1140// ── NEW helpers for st-server direct access ───────────────────────────────────1141function getStServerFromRemoteControlUrl(rcUrl) {1142  try {1143    return new URL(rcUrl).hostname;1144  } catch {1145    return null;1146  }1147}1148 1149function buildStGetFileUrl(stHost, getFileUrl, quality = "1080p") {1150  try {1151    const u = new URL(getFileUrl);1152    const newPath = u.pathname1153      .replace(/_(1080|720|480|360)p\.mp4/i, `_${quality}.mp4`)1154      .replace(/\.mp4\/?$/, `_${quality}.mp4`);1155    return `https://${stHost}${newPath}?rnd=${Date.now()}`;1156  } catch {1157    return null;1158  }1159}1160 1161// ── Browser/player runtime resolver ───────────────────────────────────────────1162async function sleep(ms) {1163  return new Promise(resolve => setTimeout(resolve, ms));1164}1165 1166function getCachedBrowser1080p(cacheKey) {1167  const cached = browser1080pCache.get(cacheKey);1168 1169  if (cached && cached.expiresAt > Date.now()) {1170    console.log(`[browser-1080p] cache hit for ${cacheKey}`);1171    return cached.value;1172  }1173 1174  if (cached) {1175    browser1080pCache.delete(cacheKey);1176  }1177 1178  return null;1179}1180 1181function setCachedBrowser1080p(cacheKey, value) {1182  browser1080pCache.set(cacheKey, {1183    value,1184    expiresAt: Date.now() + BROWSER_1080P_CACHE_MS1185  });1186}1187 1188async function closeSharedBrowser(reason = "idle") {1189  if (browserIdleTimer) {1190    clearTimeout(browserIdleTimer);1191    browserIdleTimer = null;1192  }1193 1194  const browser = sharedBrowser;1195  sharedBrowser = null;1196  sharedBrowserLaunchPromise = null;1197  browserJobsSinceRestart = 0;1198 1199  if (browser) {1200    console.log(`[browser-1080p] closing shared browser: ${reason}`);

Showing the first 1,200 of 2478 lines. Download the file for the rest.