From 44630a0ace09a75a6c89bb1b97b73a9619e8b978 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:54:28 -0600 Subject: [PATCH] add pelisplushd and vidhide embed Co-Authored-By: MoonPic <99568458+moonpic@users.noreply.github.com> --- src/providers/all.ts | 6 + src/providers/embeds/vidhide.ts | 234 +++++++++++++++++++++++++++ src/providers/sources/pelisplushd.ts | 185 +++++++++++++++++++++ 3 files changed, 425 insertions(+) create mode 100644 src/providers/embeds/vidhide.ts create mode 100644 src/providers/sources/pelisplushd.ts diff --git a/src/providers/all.ts b/src/providers/all.ts index 79900d0..040ed5d 100644 --- a/src/providers/all.ts +++ b/src/providers/all.ts @@ -40,6 +40,7 @@ import { streamwishSpanishScraper, } from './embeds/streamwish'; import { vidCloudScraper } from './embeds/vidcloud'; +import { vidhideEnglishScraper, vidhideLatinoScraper, vidhideSpanishScraper } from './embeds/vidhide'; import { vidifyEmbeds } from './embeds/vidify'; import { vidnestAllmoviesEmbed, @@ -81,6 +82,7 @@ import { lookmovieScraper } from './sources/lookmovie'; import { madplayScraper } from './sources/madplay'; import { myanimeScraper } from './sources/myanime'; import { nunflixScraper } from './sources/nunflix'; +import { pelisplushdScraper } from './sources/pelisplushd'; import { rgshowsScraper } from './sources/rgshows'; import { ridooMoviesScraper } from './sources/ridomovies'; import { slidemoviesScraper } from './sources/slidemovies'; @@ -134,6 +136,7 @@ export function gatherAllSources(): Array { animetsuScraper, lookmovieScraper, turbovidSourceScraper, + pelisplushdScraper, ]; } @@ -197,5 +200,8 @@ export function gatherAllEmbeds(): Array { myanimesubScraper, myanimedubScraper, filemoonScraper, + vidhideLatinoScraper, + vidhideSpanishScraper, + vidhideEnglishScraper, ]; } diff --git a/src/providers/embeds/vidhide.ts b/src/providers/embeds/vidhide.ts new file mode 100644 index 0000000..7b90fbf --- /dev/null +++ b/src/providers/embeds/vidhide.ts @@ -0,0 +1,234 @@ +import { flags } from '@/entrypoint/utils/targets'; +import { makeEmbed } from '@/providers/base'; +import type { EmbedScrapeContext } from '@/utils/context'; + +class Unbaser { + private ALPHABET: Record = { + 62: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 95: ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~', + }; + + private dictionary: Record = {}; + + private base: number; + + public unbase: (value: string) => number; + + constructor(base: number) { + this.base = base; + // If base is between 36 and 62, set ALPHABET accordingly + if (base > 36 && base < 62) { + this.ALPHABET[base] = this.ALPHABET[base] || this.ALPHABET[62].substring(0, base); + } + // If base is between 2 and 36, use parseInt + if (base >= 2 && base <= 36) { + this.unbase = (value: string) => parseInt(value, base); + } else { + try { + [...this.ALPHABET[base]].forEach((cipher, index) => { + this.dictionary[cipher] = index; + }); + } catch { + throw new Error('Unsupported base encoding.'); + } + this.unbase = this._dictunbaser.bind(this); + } + } + + private _dictunbaser(value: string): number { + let ret = 0; + [...value].reverse().forEach((cipher, index) => { + ret += this.base ** index * this.dictionary[cipher]; + }); + return ret; + } +} + +function _filterargs(code: string) { + const juicers = [/}\s*\('(.*)',\s*(\d+|\[\]),\s*(\d+),\s*'(.*)'\.split\('\|'\)/]; + for (const juicer of juicers) { + const args = juicer.exec(code); + if (args) { + try { + return { + payload: args[1], + radix: parseInt(args[2], 10), + count: parseInt(args[3], 10), + symtab: args[4].split('|'), + }; + } catch { + throw new Error('Corrupted p.a.c.k.e.r. data.'); + } + } + } + throw new Error('Could not make sense of p.a.c.k.e.r data (unexpected code structure)'); +} + +function unpack(packedCode: string): string { + const { payload, symtab, radix, count } = _filterargs(packedCode); + if (count !== symtab.length) throw new Error('Malformed p.a.c.k.e.r. symtab.'); + let unbase: Unbaser; + try { + unbase = new Unbaser(radix); + } catch { + throw new Error('Unknown p.a.c.k.e.r. encoding.'); + } + const lookup = (match: string): string => { + const word = match; + const word2 = radix === 1 ? symtab[parseInt(word, 10)] : symtab[unbase.unbase(word)]; + return word2 || word; + }; + return payload.replace(/\b\w+\b/g, lookup); +} + +// Official VidHide domains list +const VIDHIDE_DOMAINS = ['https://vidhidepro.com', 'https://vidhidefast.com', 'https://dinisglows.com']; + +function buildOfficialUrl(originalUrl: string, officialDomain: string): string { + try { + const u = new URL(originalUrl); + return `${officialDomain}${u.pathname}${u.search}${u.hash}`; + } catch { + return originalUrl; + } +} + +async function fetchWithOfficialDomains( + ctx: EmbedScrapeContext, + headers: Record, +): Promise<{ html: string; usedUrl: string }> { + for (const domain of VIDHIDE_DOMAINS) { + const testUrl = buildOfficialUrl(ctx.url, domain); + try { + const html = await ctx.proxiedFetcher(testUrl, { headers }); + + if (html && html.includes('eval(function(p,a,c,k,e,d')) { + return { html, usedUrl: testUrl }; + } + if (html) { + return { html, usedUrl: testUrl }; + } + } catch (err) { + // Silence errors + } + } + throw new Error('Could not get valid HTML from any official domain'); +} + +const providers = [ + { + id: 'vidhide-latino', + name: 'VidHide (Latino)', + rank: 13, + }, + { + id: 'vidhide-spanish', + name: 'VidHide (Castellano)', + rank: 14, + }, + { + id: 'vidhide-english', + name: 'VidHide (English)', + rank: 15, + }, +]; + +function extractSubtitles(unpackedScript: string): { file: string; label: string }[] { + const subtitleRegex = /{file:"([^"]+)",label:"([^"]+)"}/g; + const results: { file: string; label: string }[] = []; + const matches = unpackedScript.matchAll(subtitleRegex); + for (const match of matches) { + results.push({ file: match[1], label: match[2] }); + } + return results; +} + +function makeVidhideScraper(provider: { id: string; name: string; rank: number }) { + return makeEmbed({ + id: provider.id, + name: provider.name, + rank: provider.rank, + async scrape(ctx) { + const headers = { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Accept-Encoding': '*', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0', + }; + + const { html, usedUrl } = await fetchWithOfficialDomains(ctx, headers); + + const obfuscatedScript = html.match(/]*>\s*(eval\(function\(p,a,c,k,e,d.*?\)[\s\S]*?)<\/script>/); + if (!obfuscatedScript) { + return { stream: [], embeds: [{ embedId: provider.id, url: ctx.url }] }; + } + + let unpackedScript: string; + try { + unpackedScript = unpack(obfuscatedScript[1]); + } catch (e) { + return { stream: [], embeds: [{ embedId: provider.id, url: ctx.url }] }; + } + + const m3u8Links = Array.from(unpackedScript.matchAll(/"(http[^"]*?\.m3u8[^"]*?)"/g)).map((m) => m[1]); + + // Only look for links containing master.m3u8 + const masterUrl = m3u8Links.find((url) => url.includes('master.m3u8')); + if (!masterUrl) { + return { stream: [], embeds: [{ embedId: provider.id, url: ctx.url }] }; + } + let videoUrl = masterUrl; + + const subtitles = extractSubtitles(unpackedScript); + + try { + const m3u8Content = await ctx.proxiedFetcher(videoUrl, { + headers: { Referer: ctx.url }, + }); + const variants = Array.from( + m3u8Content.matchAll(/#EXT-X-STREAM-INF:[^\n]+\n(?!iframe)([^\n]*master\.m3u8[^\n]*)/gi), + ); + if (variants.length > 0) { + const best = variants[0]; + const base = videoUrl.substring(0, videoUrl.lastIndexOf('/') + 1); + videoUrl = base + best[1]; + } + // No else, no index, no fallback + } catch (e) { + // Silence variant errors + } + + const directHeaders = { + Referer: usedUrl, + Origin: new URL(usedUrl).origin, + }; + + return { + stream: [ + { + id: 'primary', + type: 'hls', + playlist: videoUrl, + headers: directHeaders, + flags: [flags.IP_LOCKED], + captions: subtitles.map((s: { file: string; label: string }, idx: number) => { + const ext = s.file.split('.').pop()?.toLowerCase(); + const type: 'srt' | 'vtt' = ext === 'srt' ? 'srt' : 'vtt'; + return { + type, + id: `caption-${idx}`, + url: s.file, + hasCorsRestrictions: false, + language: s.label || 'unknown', + }; + }), + }, + ], + }; + }, + }); +} + +export const [vidhideLatinoScraper, vidhideSpanishScraper, vidhideEnglishScraper] = providers.map(makeVidhideScraper); + +// made by @moonpic diff --git a/src/providers/sources/pelisplushd.ts b/src/providers/sources/pelisplushd.ts new file mode 100644 index 0000000..b81fe2a --- /dev/null +++ b/src/providers/sources/pelisplushd.ts @@ -0,0 +1,185 @@ +import { load } from 'cheerio'; +import type { Element as CheerioElement } from 'cheerio'; + +import { flags } from '@/entrypoint/utils/targets'; +import { SourcererOutput, makeSourcerer } from '@/providers/base'; +import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; +import { NotFoundError } from '@/utils/errors'; + +const baseUrl = 'https://ww3.pelisplus.to'; + +function normalizeTitle(title: string): string { + return title + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9\s-]/gi, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); +} + +function decodeBase64(str: string): string { + try { + return atob(str); + } catch { + return ''; + } +} + +function fetchUrls(text?: string): string[] { + if (!text) return []; + const linkRegex = /(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])/g; + return Array.from(text.matchAll(linkRegex)).map((m) => m[0].replace(/^"+|"+$/g, '')); +} + +async function resolvePlayerUrl(ctx: MovieScrapeContext | ShowScrapeContext, url: string): Promise { + try { + const html = await ctx.proxiedFetcher(url); + const $ = load(html); + const script = $('script:contains("window.onload")').html() || ''; + return fetchUrls(script)[0] || ''; + } catch { + return ''; + } +} + +async function extractVidhideEmbed(ctx: MovieScrapeContext | ShowScrapeContext, $: ReturnType) { + const regIsUrl = /^https?:\/\/([\w.-]+\.[a-z]{2,})(\/.*)?$/i; + const playerLinks: { idx: number; langBtn: string; url: string }[] = []; + + $('.bg-tabs ul li').each((idx: number, el: CheerioElement) => { + const li = $(el); + const langBtn = li.parent()?.parent()?.find('button').first().text().trim().toLowerCase(); + const dataServer = li.attr('data-server') || ''; + const decoded = decodeBase64(dataServer); + const url = regIsUrl.test(decoded) ? decoded : `${baseUrl}/player/${btoa(dataServer)}`; + playerLinks.push({ idx, langBtn, url }); + }); + + const results: { embedId: string; url: string }[] = []; + for (const link of playerLinks) { + let realUrl = link.url; + if (realUrl.includes('/player/')) { + realUrl = await resolvePlayerUrl(ctx, realUrl); + } + if (/vidhide/i.test(realUrl)) { + let embedId = 'vidhide'; + if (link.langBtn.includes('latino')) embedId = 'vidhide-latino'; + else if (link.langBtn.includes('castellano') || link.langBtn.includes('espaƱol')) embedId = 'vidhide-spanish'; + else if (link.langBtn.includes('ingles') || link.langBtn.includes('english')) embedId = 'vidhide-english'; + + results.push({ embedId, url: realUrl }); + } + } + return results; +} + +async function fetchTmdbTitleInSpanish(tmdbId: number, apiKey: string, mediaType: 'movie' | 'show'): Promise { + const endpoint = + mediaType === 'movie' + ? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${apiKey}&language=es-ES` + : `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${apiKey}&language=es-ES`; + + const response = await fetch(endpoint); + if (!response.ok) { + throw new Error(`Error fetching TMDB data: ${response.statusText}`); + } + const tmdbData = await response.json(); + return mediaType === 'movie' ? tmdbData.title : tmdbData.name; +} + +async function fallbackSearchByGithub( + ctx: MovieScrapeContext | ShowScrapeContext, +): Promise<{ embedId: string; url: string }[]> { + const tmdbId = ctx.media.tmdbId; + const mediaType = ctx.media.type; + if (!tmdbId) return []; + + const jsonFile = mediaType === 'movie' ? 'pelisplushd_movies.json' : 'pelisplushd_series.json'; + + let fallbacks: Record = {}; + try { + const url = `https://raw.githubusercontent.com/moonpic/fixed-titles/main/${jsonFile}`; + const response = await fetch(url); + if (!response.ok) throw new Error(); + fallbacks = await response.json(); + } catch { + return []; + } + const fallbackTitle = fallbacks[tmdbId.toString()]; + if (!fallbackTitle) return []; + const normalizedTitle = normalizeTitle(fallbackTitle); + const pageUrl = + mediaType === 'movie' + ? `${baseUrl}/pelicula/${normalizedTitle}` + : `${baseUrl}/serie/${normalizedTitle}/season/${ctx.media.season?.number}/episode/${ctx.media.episode?.number}`; + let html = ''; + try { + html = await ctx.proxiedFetcher(pageUrl); + } catch { + return []; + } + const $ = load(html); + return extractVidhideEmbed(ctx, $); +} + +async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise { + const mediaType = ctx.media.type; + const tmdbId = ctx.media.tmdbId; + const apiKey = '7604525319adb2db8e7e841cb98e9217'; // API key for TMDB + + if (!tmdbId) throw new NotFoundError('TMDB ID is required to fetch the title in Spanish'); + + let translatedTitle = ''; + try { + translatedTitle = await fetchTmdbTitleInSpanish(Number(tmdbId), apiKey, mediaType); + } catch { + throw new NotFoundError('Could not get the title from TMDB'); + } + const normalizedTitle = normalizeTitle(translatedTitle); + + const pageUrl = + mediaType === 'movie' + ? `${baseUrl}/pelicula/${normalizedTitle}` + : `${baseUrl}/serie/${normalizedTitle}/season/${ctx.media.season?.number}/episode/${ctx.media.episode?.number}`; + + ctx.progress(60); + + let html = ''; + try { + html = await ctx.proxiedFetcher(pageUrl); + } catch { + html = ''; + } + + let embeds: { embedId: string; url: string }[] = []; + if (html) { + const $ = load(html); + try { + embeds = await extractVidhideEmbed(ctx, $); + } catch { + embeds = []; + } + } + + if (!embeds.length) { + embeds = await fallbackSearchByGithub(ctx); + } + + if (!embeds.length) { + throw new NotFoundError('No vidhide embed found in PelisPlusHD'); + } + + return { embeds }; +} + +export const pelisplushdScraper = makeSourcerer({ + id: 'pelisplushd', + name: 'PelisPlusHD', + rank: 75, + flags: [flags.IP_LOCKED], // Vidhide embeds are IP locked + scrapeMovie: comboScraper, + scrapeShow: comboScraper, +}); + +// made by @moonpic