diff --git a/src/dev-cli/tmdb.ts b/src/dev-cli/tmdb.ts index 7490336..c03307d 100644 --- a/src/dev-cli/tmdb.ts +++ b/src/dev-cli/tmdb.ts @@ -2,7 +2,7 @@ import { getConfig } from '@/dev-cli/config'; import { MovieMedia, ShowMedia } from '..'; -export async function makeTMDBRequest(url: string): Promise { +export async function makeTMDBRequest(url: string, appendToResponse?: string): Promise { const headers: { accept: 'application/json'; authorization?: string; @@ -10,7 +10,7 @@ export async function makeTMDBRequest(url: string): Promise { accept: 'application/json', }; - let requestURL = url; + const requestURL = new URL(url); const key = getConfig().tmdbApiKey; // * JWT keys always start with ey and are ONLY valid as a header. @@ -19,7 +19,11 @@ export async function makeTMDBRequest(url: string): Promise { if (key.startsWith('ey')) { headers.authorization = `Bearer ${key}`; } else { - requestURL += `?api_key=${key}`; + requestURL.searchParams.append('api_key', key); + } + + if (appendToResponse) { + requestURL.searchParams.append('append_to_response', appendToResponse); } return fetch(requestURL, { @@ -29,7 +33,7 @@ export async function makeTMDBRequest(url: string): Promise { } export async function getMovieMediaDetails(id: string): Promise { - const response = await makeTMDBRequest(`https://api.themoviedb.org/3/movie/${id}`); + const response = await makeTMDBRequest(`https://api.themoviedb.org/3/movie/${id}`, 'external_ids'); const movie = await response.json(); if (movie.success === false) { @@ -45,13 +49,14 @@ export async function getMovieMediaDetails(id: string): Promise { title: movie.title, releaseYear: Number(movie.release_date.split('-')[0]), tmdbId: id, + imdbId: movie.imdb_id, }; } export async function getShowMediaDetails(id: string, seasonNumber: string, episodeNumber: string): Promise { // * TV shows require the TMDB ID for the series, season, and episode // * and the name of the series. Needs multiple requests - let response = await makeTMDBRequest(`https://api.themoviedb.org/3/tv/${id}`); + let response = await makeTMDBRequest(`https://api.themoviedb.org/3/tv/${id}`, 'external_ids'); const series = await response.json(); if (series.success === false) { @@ -91,5 +96,6 @@ export async function getShowMediaDetails(id: string, seasonNumber: string, epis number: season.season_number, tmdbId: season.id, }, + imdbId: series.external_ids.imdb_id, }; } diff --git a/src/providers/all.ts b/src/providers/all.ts index c2eb771..de22b4c 100644 --- a/src/providers/all.ts +++ b/src/providers/all.ts @@ -17,9 +17,12 @@ import { showboxScraper } from '@/providers/sources/showbox/index'; import { vidsrcScraper } from '@/providers/sources/vidsrc/index'; import { zoechipScraper } from '@/providers/sources/zoechip'; +import { fileMoonScraper } from './embeds/filemoon'; import { smashyStreamDScraper } from './embeds/smashystream/dued'; import { smashyStreamFScraper } from './embeds/smashystream/video1'; +import { vidplayScraper } from './embeds/vidplay'; import { smashyStreamScraper } from './sources/smashystream'; +import { vidSrcToScraper } from './sources/vidsrcto'; export function gatherAllSources(): Array { // all sources are gathered here @@ -33,6 +36,7 @@ export function gatherAllSources(): Array { vidsrcScraper, lookmovieScraper, smashyStreamScraper, + vidSrcToScraper, ]; } @@ -50,5 +54,7 @@ export function gatherAllEmbeds(): Array { streambucketScraper, smashyStreamFScraper, smashyStreamDScraper, + fileMoonScraper, + vidplayScraper, ]; } diff --git a/src/providers/embeds/filemoon/index.ts b/src/providers/embeds/filemoon/index.ts new file mode 100644 index 0000000..9f96a07 --- /dev/null +++ b/src/providers/embeds/filemoon/index.ts @@ -0,0 +1,58 @@ +import { unpack } from 'unpacker'; + +import { SubtitleResult } from './types'; +import { makeEmbed } from '../../base'; +import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '../../captions'; + +const evalCodeRegex = /eval\((.*)\)/g; +const fileRegex = /file:"(.*?)"/g; + +export const fileMoonScraper = makeEmbed({ + id: 'filemoon', + name: 'Filemoon', + rank: 400, + scrape: async (ctx) => { + const embedRes = await ctx.proxiedFetcher(ctx.url, { + headers: { + referer: ctx.url, + }, + }); + const evalCode = evalCodeRegex.exec(embedRes); + if (!evalCode) throw new Error('Failed to find eval code'); + const unpacked = unpack(evalCode[1]); + const file = fileRegex.exec(unpacked); + if (!file?.[1]) throw new Error('Failed to find file'); + + const url = new URL(ctx.url); + const subtitlesLink = url.searchParams.get('sub.info'); + const captions: Caption[] = []; + if (subtitlesLink) { + const captionsResult = await ctx.proxiedFetcher(subtitlesLink); + + for (const caption of captionsResult) { + const language = labelToLanguageCode(caption.label); + const captionType = getCaptionTypeFromUrl(caption.file); + if (!language || !captionType) continue; + captions.push({ + id: caption.file, + url: caption.file, + type: captionType, + language, + hasCorsRestrictions: false, + }); + } + } + + return { + stream: [ + { + id: 'primary', + type: 'hls', + playlist: file[1], + flags: [], + captions, + }, + ], + }; + }, +}); diff --git a/src/providers/embeds/filemoon/types.ts b/src/providers/embeds/filemoon/types.ts new file mode 100644 index 0000000..caa27af --- /dev/null +++ b/src/providers/embeds/filemoon/types.ts @@ -0,0 +1,5 @@ +export type SubtitleResult = { + file: string; + label: string; + kind: string; +}[]; diff --git a/src/providers/embeds/vidplay/common.ts b/src/providers/embeds/vidplay/common.ts new file mode 100644 index 0000000..224e3dc --- /dev/null +++ b/src/providers/embeds/vidplay/common.ts @@ -0,0 +1,54 @@ +import { makeFullUrl } from '@/fetchers/common'; +import { decodeData } from '@/providers/sources/vidsrcto/common'; +import { EmbedScrapeContext } from '@/utils/context'; + +export const vidplayBase = 'https://vidplay.site'; + +// This file is based on https://github.com/Ciarands/vidsrc-to-resolver/blob/dffa45e726a4b944cb9af0c9e7630476c93c0213/vidsrc.py#L16 +// Full credits to @Ciarands! + +export const getDecryptionKeys = async (ctx: EmbedScrapeContext): Promise => { + const res = await ctx.fetcher( + 'https://raw.githubusercontent.com/Claudemirovsky/worstsource-keys/keys/keys.json', + ); + return JSON.parse(res); +}; + +export const getEncodedId = async (ctx: EmbedScrapeContext) => { + const url = new URL(ctx.url); + const id = url.pathname.replace('/e/', ''); + const keyList = await getDecryptionKeys(ctx); + + const decodedId = decodeData(keyList[0], id); + const encodedResult = decodeData(keyList[1], decodedId); + const b64encoded = btoa(encodedResult); + return b64encoded.replace('/', '_'); +}; + +export const getFuTokenKey = async (ctx: EmbedScrapeContext) => { + const id = await getEncodedId(ctx); + const fuTokenRes = await ctx.proxiedFetcher('/futoken', { + baseUrl: vidplayBase, + headers: { + referer: ctx.url, + }, + }); + const fuKey = fuTokenRes.match(/var\s+k\s*=\s*'([^']+)'/)?.[1]; + if (!fuKey) throw new Error('No fuKey found'); + const tokens = []; + for (let i = 0; i < id.length; i += 1) { + tokens.push(fuKey.charCodeAt(i % fuKey.length) + id.charCodeAt(i)); + } + return `${fuKey},${tokens.join(',')}`; +}; + +export const getFileUrl = async (ctx: EmbedScrapeContext) => { + const fuToken = await getFuTokenKey(ctx); + return makeFullUrl(`/mediainfo/${fuToken}`, { + baseUrl: vidplayBase, + query: { + ...Object.fromEntries(new URL(ctx.url).searchParams.entries()), + autostart: 'true', + }, + }); +}; diff --git a/src/providers/embeds/vidplay/index.ts b/src/providers/embeds/vidplay/index.ts new file mode 100644 index 0000000..3c1f6a2 --- /dev/null +++ b/src/providers/embeds/vidplay/index.ts @@ -0,0 +1,53 @@ +import { makeEmbed } from '@/providers/base'; +import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '@/providers/captions'; + +import { getFileUrl } from './common'; +import { SubtitleResult, VidplaySourceResponse } from './types'; + +export const vidplayScraper = makeEmbed({ + id: 'vidplay', + name: 'VidPlay', + rank: 401, + scrape: async (ctx) => { + const fileUrl = await getFileUrl(ctx); + const fileUrlRes = await ctx.proxiedFetcher(fileUrl, { + headers: { + referer: ctx.url, + }, + }); + if (typeof fileUrlRes.result === 'number') throw new Error('File not found'); + const source = fileUrlRes.result.sources[0].file; + + const url = new URL(ctx.url); + const subtitlesLink = url.searchParams.get('sub.info'); + const captions: Caption[] = []; + if (subtitlesLink) { + const captionsResult = await ctx.proxiedFetcher(subtitlesLink); + + for (const caption of captionsResult) { + const language = labelToLanguageCode(caption.label); + const captionType = getCaptionTypeFromUrl(caption.file); + if (!language || !captionType) continue; + captions.push({ + id: caption.file, + url: caption.file, + type: captionType, + language, + hasCorsRestrictions: false, + }); + } + } + + return { + stream: [ + { + id: 'primary', + type: 'hls', + playlist: source, + flags: [], + captions, + }, + ], + }; + }, +}); diff --git a/src/providers/embeds/vidplay/types.ts b/src/providers/embeds/vidplay/types.ts new file mode 100644 index 0000000..29cde1d --- /dev/null +++ b/src/providers/embeds/vidplay/types.ts @@ -0,0 +1,19 @@ +export type VidplaySourceResponse = { + result: + | { + sources: { + file: string; + tracks: { + file: string; + kind: string; + }[]; + }[]; + } + | number; +}; + +export type SubtitleResult = { + file: string; + label: string; + kind: string; +}[]; diff --git a/src/providers/sources/vidsrcto/common.ts b/src/providers/sources/vidsrcto/common.ts new file mode 100644 index 0000000..2c7272f --- /dev/null +++ b/src/providers/sources/vidsrcto/common.ts @@ -0,0 +1,49 @@ +// This file is based on https://github.com/Ciarands/vidsrc-to-resolver/blob/dffa45e726a4b944cb9af0c9e7630476c93c0213/vidsrc.py#L16 +// Full credits to @Ciarands! + +const DECRYPTION_KEY = '8z5Ag5wgagfsOuhz'; + +export const decodeBase64UrlSafe = (str: string) => { + const standardizedInput = str.replace(/_/g, '/').replace(/-/g, '+'); + const decodedData = atob(standardizedInput); + + const bytes = new Uint8Array(decodedData.length); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = decodedData.charCodeAt(i); + } + + return bytes; +}; + +export const decodeData = (key: string, data: any) => { + const state = Array.from(Array(256).keys()); + let index1 = 0; + for (let i = 0; i < 256; i += 1) { + index1 = (index1 + state[i] + key.charCodeAt(i % key.length)) % 256; + const temp = state[i]; + state[i] = state[index1]; + state[index1] = temp; + } + index1 = 0; + let index2 = 0; + let finalKey = ''; + for (let char = 0; char < data.length; char += 1) { + index1 = (index1 + 1) % 256; + index2 = (index2 + state[index1]) % 256; + const temp = state[index1]; + state[index1] = state[index2]; + state[index2] = temp; + if (typeof data[char] === 'string') { + finalKey += String.fromCharCode(data[char].charCodeAt(0) ^ state[(state[index1] + state[index2]) % 256]); + } else if (typeof data[char] === 'number') { + finalKey += String.fromCharCode(data[char] ^ state[(state[index1] + state[index2]) % 256]); + } + } + return finalKey; +}; + +export const decryptSourceUrl = (sourceUrl: string) => { + const encoded = decodeBase64UrlSafe(sourceUrl); + const decoded = decodeData(DECRYPTION_KEY, encoded); + return decodeURIComponent(decodeURIComponent(decoded)); +}; diff --git a/src/providers/sources/vidsrcto/index.ts b/src/providers/sources/vidsrcto/index.ts new file mode 100644 index 0000000..b85b068 --- /dev/null +++ b/src/providers/sources/vidsrcto/index.ts @@ -0,0 +1,84 @@ +import { load } from 'cheerio'; + +import { SourcererEmbed, SourcererOutput, makeSourcerer } from '@/providers/base'; +import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; + +import { decryptSourceUrl } from './common'; +import { SourceResult, SourcesResult } from './types'; + +const vidSrcToBase = 'https://vidsrc.to'; +const referer = `${vidSrcToBase}/`; + +const universalScraper = async (ctx: ShowScrapeContext | MovieScrapeContext): Promise => { + const imdbId = ctx.media.imdbId; + const url = + ctx.media.type === 'movie' + ? `/embed/movie/${imdbId}` + : `/embed/tv/${imdbId}/${ctx.media.season.number}/${ctx.media.episode.number}`; + const mainPage = await ctx.proxiedFetcher(url, { + baseUrl: vidSrcToBase, + headers: { + referer, + }, + }); + const mainPage$ = load(mainPage); + const dataId = mainPage$('a[data-id]').attr('data-id'); + if (!dataId) throw new Error('No data-id found'); + const sources = await ctx.proxiedFetcher(`/ajax/embed/episode/${dataId}/sources`, { + baseUrl: vidSrcToBase, + headers: { + referer, + }, + }); + if (sources.status !== 200) throw new Error('No sources found'); + + const embeds: SourcererEmbed[] = []; + const embedUrls = []; + for (const source of sources.result) { + const sourceRes = await ctx.proxiedFetcher(`/ajax/embed/source/${source.id}`, { + baseUrl: vidSrcToBase, + headers: { + referer, + }, + }); + const decryptedUrl = decryptSourceUrl(sourceRes.result.url); + embedUrls.push(decryptedUrl); + } + + // Originally Filemoon does not have subtitles. But we can use the ones from Vidplay. + const subtitleUrl = new URL(embedUrls.find((v) => v.includes('sub.info')) ?? '').searchParams.get('sub.info'); + for (const source of sources.result) { + if (source.title === 'Vidplay') { + const embedUrl = embedUrls.find((v) => v.includes('vidplay')); + if (!embedUrl) continue; + embeds.push({ + embedId: 'vidplay', + url: embedUrl, + }); + } + + if (source.title === 'Filemoon') { + const embedUrl = embedUrls.find((v) => v.includes('filemoon')); + if (!embedUrl) continue; + const fullUrl = new URL(embedUrl); + if (subtitleUrl) fullUrl.searchParams.set('sub.info', subtitleUrl); + embeds.push({ + embedId: 'filemoon', + url: fullUrl.toString(), + }); + } + } + + return { + embeds, + }; +}; + +export const vidSrcToScraper = makeSourcerer({ + id: 'vidsrcto', + name: 'VidSrcTo', + scrapeMovie: universalScraper, + scrapeShow: universalScraper, + flags: [], + rank: 400, +}); diff --git a/src/providers/sources/vidsrcto/types.ts b/src/providers/sources/vidsrcto/types.ts new file mode 100644 index 0000000..0694b15 --- /dev/null +++ b/src/providers/sources/vidsrcto/types.ts @@ -0,0 +1,15 @@ +export type VidSrcToResponse = { + status: number; + result: T; +}; + +export type SourcesResult = VidSrcToResponse< + { + id: string; + title: 'Filemoon' | 'Vidplay'; + }[] +>; + +export type SourceResult = VidSrcToResponse<{ + url: string; +}>;