diff --git a/package-lock.json b/package-lock.json index 87a5e4f..40a870f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@movie-web/providers", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@movie-web/providers", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "cheerio": "^1.0.0-rc.12", diff --git a/src/dev-cli.ts b/src/dev-cli.ts index 4989354..54d663a 100644 --- a/src/dev-cli.ts +++ b/src/dev-cli.ts @@ -1,5 +1,7 @@ /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ +import util from 'node:util'; + import { program } from 'commander'; import dotenv from 'dotenv'; import { prompt } from 'enquirer'; @@ -45,6 +47,10 @@ if (!TMDB_API_KEY?.trim()) { throw new Error('Missing MOVIE_WEB_TMDB_API_KEY environment variable'); } +function logDeepObject(object: Record) { + console.log(util.inspect(object, { showHidden: false, depth: null, colors: true })); +} + function getAllSources() { // * The only way to get a list of all sources is to // * create all these things. Maybe this should change @@ -181,7 +187,7 @@ async function runScraper(providers: ProviderControls, source: MetaOutput, optio id: source.id, }); spinnies.succeed('scrape', { text: 'Done!' }); - console.log(result); + logDeepObject(result); } catch (error) { let message = 'Unknown error'; if (error instanceof Error) { @@ -206,7 +212,7 @@ async function runScraper(providers: ProviderControls, source: MetaOutput, optio id: source.id, }); spinnies.succeed('scrape', { text: 'Done!' }); - console.log(result); + logDeepObject(result); } catch (error) { let message = 'Unknown error'; if (error instanceof Error) { diff --git a/src/main/individualRunner.ts b/src/main/individualRunner.ts index 957edd0..ac563ea 100644 --- a/src/main/individualRunner.ts +++ b/src/main/individualRunner.ts @@ -6,6 +6,7 @@ import { EmbedOutput, SourcererOutput } from '@/providers/base'; import { ProviderList } from '@/providers/get'; import { ScrapeContext } from '@/utils/context'; import { NotFoundError } from '@/utils/errors'; +import { isValidStream } from '@/utils/valid'; export type IndividualSourceRunnerOptions = { features: FeatureMap; @@ -50,7 +51,7 @@ export async function scrapeInvidualSource( }); // stream doesn't satisfy the feature flags, so gets removed in output - if (output?.stream && !flagsAllowedInFeatures(ops.features, output.stream.flags)) { + if (output?.stream && (!isValidStream(output.stream) || !flagsAllowedInFeatures(ops.features, output.stream.flags))) { output.stream = undefined; } @@ -87,7 +88,9 @@ export async function scrapeIndividualEmbed( }, }); + if (!isValidStream(output.stream)) throw new NotFoundError('stream is incomplete'); if (!flagsAllowedInFeatures(ops.features, output.stream.flags)) throw new NotFoundError("stream doesn't satisfy target feature flags"); + return output; } diff --git a/src/main/runner.ts b/src/main/runner.ts index 0ecaee1..866ffb9 100644 --- a/src/main/runner.ts +++ b/src/main/runner.ts @@ -8,6 +8,7 @@ import { Stream } from '@/providers/streams'; import { ScrapeContext } from '@/utils/context'; import { NotFoundError } from '@/utils/errors'; import { reorderOnIdList } from '@/utils/list'; +import { isValidStream } from '@/utils/valid'; export type RunOutput = { sourceId: string; @@ -79,6 +80,9 @@ export async function runAllProviders(list: ProviderList, ops: ProviderRunnerOpt ...contextBase, media: ops.media, }); + if (!isValidStream(output?.stream)) { + throw new NotFoundError('stream is incomplete'); + } if (output?.stream && !flagsAllowedInFeatures(ops.features, output.stream.flags)) { throw new NotFoundError("stream doesn't satisfy target feature flags"); } diff --git a/src/providers/all.ts b/src/providers/all.ts index ef26094..49ff4f2 100644 --- a/src/providers/all.ts +++ b/src/providers/all.ts @@ -1,4 +1,5 @@ import { Embed, Sourcerer } from '@/providers/base'; +import { febBoxScraper } from '@/providers/embeds/febBox'; import { mixdropScraper } from '@/providers/embeds/mixdrop'; import { mp4uploadScraper } from '@/providers/embeds/mp4upload'; import { streamsbScraper } from '@/providers/embeds/streamsb'; @@ -11,12 +12,22 @@ import { remotestreamScraper } from '@/providers/sources/remotestream'; import { superStreamScraper } from '@/providers/sources/superstream/index'; import { zoechipScraper } from '@/providers/sources/zoechip'; +import { showBoxScraper } from './sources/showbox'; + export function gatherAllSources(): Array { // all sources are gathered here - return [flixhqScraper, remotestreamScraper, kissAsianScraper, superStreamScraper, goMoviesScraper, zoechipScraper]; + return [ + flixhqScraper, + remotestreamScraper, + kissAsianScraper, + superStreamScraper, + goMoviesScraper, + zoechipScraper, + showBoxScraper, + ]; } export function gatherAllEmbeds(): Array { // all embeds are gathered here - return [upcloudScraper, mp4uploadScraper, streamsbScraper, upstreamScraper, mixdropScraper]; + return [upcloudScraper, mp4uploadScraper, streamsbScraper, upstreamScraper, febBoxScraper, mixdropScraper]; } diff --git a/src/providers/embeds/febBox.ts b/src/providers/embeds/febBox.ts new file mode 100644 index 0000000..06a3414 --- /dev/null +++ b/src/providers/embeds/febBox.ts @@ -0,0 +1,73 @@ +import { flags } from '@/main/targets'; +import { makeEmbed } from '@/providers/base'; +import { StreamFile } from '@/providers/streams'; +import { NotFoundError } from '@/utils/errors'; + +const febBoxBase = `https://www.febbox.com`; + +const allowedQualities = ['360', '480', '720', '1080']; + +export const febBoxScraper = makeEmbed({ + id: 'febbox', + name: 'FebBox', + rank: 160, + async scrape(ctx) { + const shareKey = ctx.url.split('/')[4]; + const streams = await ctx.proxiedFetcher<{ + data?: { + file_list?: { + fid?: string; + }[]; + }; + }>('/file/file_share_list', { + headers: { + 'accept-language': 'en', // without this header, the request is marked as a webscraper + }, + baseUrl: febBoxBase, + query: { + share_key: shareKey, + pwd: '', + }, + }); + + const fid = streams?.data?.file_list?.[0]?.fid; + if (!fid) throw new NotFoundError('no result found'); + + const formParams = new URLSearchParams(); + formParams.append('fid', fid); + formParams.append('share_key', shareKey); + + const player = await ctx.proxiedFetcher('/file/player', { + baseUrl: febBoxBase, + body: formParams, + method: 'POST', + headers: { + 'accept-language': 'en', // without this header, the request is marked as a webscraper + }, + }); + + const sourcesMatch = player?.match(/var sources = (\[[^\]]+\]);/); + const qualities = sourcesMatch ? JSON.parse(sourcesMatch[0].replace('var sources = ', '').replace(';', '')) : null; + + const embedQualities: Record = {}; + + qualities.forEach((quality: { file: string; label: string }) => { + const normalizedLabel = quality.label.toLowerCase().replace('p', ''); + if (allowedQualities.includes(normalizedLabel)) { + if (!quality.file) return; + embedQualities[normalizedLabel] = { + type: 'mp4', + url: quality.file, + }; + } + }); + + return { + stream: { + type: 'file', + flags: [flags.NO_CORS], + qualities: embedQualities, + }, + }; + }, +}); diff --git a/src/providers/sources/flixhq/index.ts b/src/providers/sources/flixhq/index.ts index fcd504d..e5cae5b 100644 --- a/src/providers/sources/flixhq/index.ts +++ b/src/providers/sources/flixhq/index.ts @@ -1,7 +1,7 @@ import { flags } from '@/main/targets'; import { makeSourcerer } from '@/providers/base'; import { upcloudScraper } from '@/providers/embeds/upcloud'; -import { getFlixhqSourceDetails, getFlixhqSources } from '@/providers/sources/flixhq/scrape'; +import { getFlixhqMovieSources, getFlixhqShowSources, getFlixhqSourceDetails } from '@/providers/sources/flixhq/scrape'; import { getFlixhqId } from '@/providers/sources/flixhq/search'; import { NotFoundError } from '@/utils/errors'; @@ -15,10 +15,27 @@ export const flixhqScraper = makeSourcerer({ const id = await getFlixhqId(ctx, ctx.media); if (!id) throw new NotFoundError('no search results match'); - const sources = await getFlixhqSources(ctx, id); + const sources = await getFlixhqMovieSources(ctx, ctx.media, id); const upcloudStream = sources.find((v) => v.embed.toLowerCase() === 'upcloud'); if (!upcloudStream) throw new NotFoundError('upcloud stream not found for flixhq'); + return { + embeds: [ + { + embedId: upcloudScraper.id, + url: await getFlixhqSourceDetails(ctx, upcloudStream.episodeId), + }, + ], + }; + }, + async scrapeShow(ctx) { + const id = await getFlixhqId(ctx, ctx.media); + if (!id) throw new NotFoundError('no search results match'); + + const sources = await getFlixhqShowSources(ctx, ctx.media, id); + const upcloudStream = sources.find((v) => v.embed.toLowerCase() === 'server upcloud'); + if (!upcloudStream) throw new NotFoundError('upcloud stream not found for flixhq'); + return { embeds: [ { diff --git a/src/providers/sources/flixhq/scrape.ts b/src/providers/sources/flixhq/scrape.ts index 5c25682..a73916c 100644 --- a/src/providers/sources/flixhq/scrape.ts +++ b/src/providers/sources/flixhq/scrape.ts @@ -1,16 +1,26 @@ import { load } from 'cheerio'; +import { MovieMedia, ShowMedia } from '@/main/media'; import { flixHqBase } from '@/providers/sources/flixhq/common'; import { ScrapeContext } from '@/utils/context'; +import { NotFoundError } from '@/utils/errors'; -export async function getFlixhqSources(ctx: ScrapeContext, id: string) { - const type = id.split('/')[0]; +export async function getFlixhqSourceDetails(ctx: ScrapeContext, sourceId: string): Promise { + const jsonData = await ctx.proxiedFetcher>(`/ajax/sources/${sourceId}`, { + baseUrl: flixHqBase, + }); + + return jsonData.link; +} + +export async function getFlixhqMovieSources(ctx: ScrapeContext, media: MovieMedia, id: string) { const episodeParts = id.split('-'); const episodeId = episodeParts[episodeParts.length - 1]; - const data = await ctx.proxiedFetcher(`/ajax/${type}/episodes/${episodeId}`, { + const data = await ctx.proxiedFetcher(`/ajax/movie/episodes/${episodeId}`, { baseUrl: flixHqBase, }); + const doc = load(data); const sourceLinks = doc('.nav-item > a') .toArray() @@ -28,10 +38,55 @@ export async function getFlixhqSources(ctx: ScrapeContext, id: string) { return sourceLinks; } -export async function getFlixhqSourceDetails(ctx: ScrapeContext, sourceId: string): Promise { - const jsonData = await ctx.proxiedFetcher>(`/ajax/sources/${sourceId}`, { +export async function getFlixhqShowSources(ctx: ScrapeContext, media: ShowMedia, id: string) { + const episodeParts = id.split('-'); + const episodeId = episodeParts[episodeParts.length - 1]; + + const seasonsListData = await ctx.proxiedFetcher(`/ajax/season/list/${episodeId}`, { baseUrl: flixHqBase, }); - return jsonData.link; + const seasonsDoc = load(seasonsListData); + const season = seasonsDoc('.dropdown-item') + .toArray() + .find((el) => seasonsDoc(el).text() === `Season ${media.season.number}`)?.attribs['data-id']; + + if (!season) throw new NotFoundError('season not found'); + + const seasonData = await ctx.proxiedFetcher(`/ajax/season/episodes/${season}`, { + baseUrl: flixHqBase, + }); + const seasonDoc = load(seasonData); + const episode = seasonDoc('.nav-item > a') + .toArray() + .map((el) => { + return { + id: seasonDoc(el).attr('data-id'), + title: seasonDoc(el).attr('title'), + }; + }) + .find((e) => e.title?.startsWith(`Eps ${media.episode.number}`))?.id; + + if (!episode) throw new NotFoundError('episode not found'); + + const data = await ctx.proxiedFetcher(`/ajax/episode/servers/${episode}`, { + baseUrl: flixHqBase, + }); + + const doc = load(data); + + const sourceLinks = doc('.nav-item > a') + .toArray() + .map((el) => { + const query = doc(el); + const embedTitle = query.attr('title'); + const linkId = query.attr('data-id'); + if (!embedTitle || !linkId) throw new Error('invalid sources'); + return { + embed: embedTitle, + episodeId: linkId, + }; + }); + + return sourceLinks; } diff --git a/src/providers/sources/flixhq/search.ts b/src/providers/sources/flixhq/search.ts index 569d2d1..90517f6 100644 --- a/src/providers/sources/flixhq/search.ts +++ b/src/providers/sources/flixhq/search.ts @@ -1,11 +1,11 @@ import { load } from 'cheerio'; -import { MovieMedia } from '@/main/media'; +import { MovieMedia, ShowMedia } from '@/main/media'; import { flixHqBase } from '@/providers/sources/flixhq/common'; import { compareMedia } from '@/utils/compare'; import { ScrapeContext } from '@/utils/context'; -export async function getFlixhqId(ctx: ScrapeContext, media: MovieMedia): Promise { +export async function getFlixhqId(ctx: ScrapeContext, media: MovieMedia | ShowMedia): Promise { const searchResults = await ctx.proxiedFetcher(`/search/${media.title.replaceAll(/[^a-z0-9A-Z]/g, '-')}`, { baseUrl: flixHqBase, }); @@ -23,7 +23,7 @@ export async function getFlixhqId(ctx: ScrapeContext, media: MovieMedia): Promis return { id, title, - year: +year, + year: parseInt(year, 10), }; }); diff --git a/src/providers/sources/showbox/index.ts b/src/providers/sources/showbox/index.ts new file mode 100644 index 0000000..1534068 --- /dev/null +++ b/src/providers/sources/showbox/index.ts @@ -0,0 +1,63 @@ +import { load } from 'cheerio'; + +import { flags } from '@/main/targets'; +import { makeSourcerer } from '@/providers/base'; +import { febBoxScraper } from '@/providers/embeds/febBox'; +import { compareMedia } from '@/utils/compare'; +import { NotFoundError } from '@/utils/errors'; + +const showboxBase = `https://www.showbox.media`; + +export const showBoxScraper = makeSourcerer({ + id: 'showBox', + name: 'ShowBox', + rank: 20, + flags: [flags.NO_CORS], + async scrapeMovie(ctx) { + const search = await ctx.proxiedFetcher('/search', { + baseUrl: showboxBase, + query: { + keyword: ctx.media.title, + }, + }); + + const searchPage = load(search); + const result = searchPage('.film-name > a') + .toArray() + .map((el) => { + const titleContainer = el.parent?.parent; + if (!titleContainer) return; + const year = searchPage(titleContainer).find('.fdi-item').first().text(); + + return { + title: el.attribs.title, + path: el.attribs.href, + year: !year.includes('SS') ? parseInt(year, 10) : undefined, + }; + }) + .find((v) => v && compareMedia(ctx.media, v.title, v.year ? v.year : undefined)); + + if (!result?.path) throw new NotFoundError('no result found'); + + const febboxResult = await ctx.proxiedFetcher<{ + data?: { link?: string }; + }>('/index/share_link', { + baseUrl: showboxBase, + query: { + id: result.path.split('/')[3], + type: '1', + }, + }); + + if (!febboxResult?.data?.link) throw new NotFoundError('no result found'); + + return { + embeds: [ + { + embedId: febBoxScraper.id, + url: febboxResult.data.link, + }, + ], + }; + }, +}); diff --git a/src/providers/sources/superstream/getStreamQualities.ts b/src/providers/sources/superstream/getStreamQualities.ts index 6fe55a3..f968a18 100644 --- a/src/providers/sources/superstream/getStreamQualities.ts +++ b/src/providers/sources/superstream/getStreamQualities.ts @@ -3,7 +3,7 @@ import { ScrapeContext } from '@/utils/context'; import { sendRequest } from './sendRequest'; -import { allowedQualities } from '.'; +const allowedQualities = ['360', '480', '720', '1080']; export async function getStreamQualities(ctx: ScrapeContext, apiQuery: object) { const mediaRes: { list: { path: string; real_quality: string }[] } = (await sendRequest(ctx, apiQuery)).data; @@ -20,7 +20,7 @@ export async function getStreamQualities(ctx: ScrapeContext, apiQuery: object) { allowedQualities.forEach((quality) => { const foundQuality = qualityMap.find((q) => q.quality === quality); - if (foundQuality) { + if (foundQuality && foundQuality.url) { qualities[quality] = { type: 'mp4', url: foundQuality.url, diff --git a/src/providers/sources/superstream/index.ts b/src/providers/sources/superstream/index.ts index 45f64b2..78972ea 100644 --- a/src/providers/sources/superstream/index.ts +++ b/src/providers/sources/superstream/index.ts @@ -6,8 +6,6 @@ import { NotFoundError } from '@/utils/errors'; import { getStreamQualities } from './getStreamQualities'; import { sendRequest } from './sendRequest'; -export const allowedQualities = ['360', '480', '720', '1080']; - export const superStreamScraper = makeSourcerer({ id: 'superstream', name: 'Superstream', diff --git a/src/utils/errors.ts b/src/utils/errors.ts index d31f7d8..0c83611 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,6 +1,6 @@ export class NotFoundError extends Error { constructor(reason?: string) { - super(`Couldn't found a stream: ${reason ?? 'not found'}`); + super(`Couldn't find a stream: ${reason ?? 'not found'}`); this.name = 'NotFoundError'; } } diff --git a/src/utils/valid.ts b/src/utils/valid.ts new file mode 100644 index 0000000..62347c3 --- /dev/null +++ b/src/utils/valid.ts @@ -0,0 +1,17 @@ +import { Stream } from '@/providers/streams'; + +export function isValidStream(stream: Stream | undefined): boolean { + if (!stream) return false; + if (stream.type === 'hls') { + if (!stream.playlist) return false; + return true; + } + if (stream.type === 'file') { + const validQualities = Object.values(stream.qualities).filter((v) => v.url.length > 0); + if (validQualities.length === 0) return false; + return true; + } + + // unknown file type + return false; +}