Merge branch 'dev' into lookmovie

This commit is contained in:
mrjvs
2023-11-25 15:21:37 +01:00
committed by GitHub
28 changed files with 1761 additions and 2709 deletions

3895
.docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

2
.docs/package.json Executable file → Normal file
View File

@@ -11,7 +11,7 @@
}, },
"devDependencies": { "devDependencies": {
"@nuxt-themes/docus": "^1.13.1", "@nuxt-themes/docus": "^1.13.1",
"@nuxt/devtools": "^0.6.7", "@nuxt/devtools": "^1.0.1",
"@nuxt/eslint-config": "^0.1.1", "@nuxt/eslint-config": "^0.1.1",
"@nuxtjs/plausible": "^0.2.1", "@nuxtjs/plausible": "^0.2.1",
"@types/node": "^20.4.0", "@types/node": "^20.4.0",

25
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{ {
"name": "@movie-web/providers", "name": "@movie-web/providers",
"version": "1.0.1", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@movie-web/providers", "name": "@movie-web/providers",
"version": "1.0.1", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"json5": "^2.2.3", "json5": "^2.2.3",
"iso-639-1": "^3.1.0",
"nanoid": "^3.3.6", "nanoid": "^3.3.6",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"unpacker": "^1.0.1" "unpacker": "^1.0.1"
@@ -2184,9 +2185,9 @@
} }
}, },
"node_modules/crypto-js": { "node_modules/crypto-js": {
"version": "4.1.1", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
}, },
"node_modules/css-select": { "node_modules/css-select": {
"version": "5.1.0", "version": "5.1.0",
@@ -4278,6 +4279,14 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true "dev": true
}, },
"node_modules/iso-639-1": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.0.tgz",
"integrity": "sha512-rWcHp9dcNbxa5C8jA/cxFlWNFNwy5Vup0KcFvgA8sPQs9ZeJHj/Eq0Y8Yz2eL8XlWYpxw4iwh9FfTeVxyqdRMw==",
"engines": {
"node": ">=6.0"
}
},
"node_modules/istanbul-lib-coverage": { "node_modules/istanbul-lib-coverage": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
@@ -5116,9 +5125,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.24", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@movie-web/providers", "name": "@movie-web/providers",
"version": "1.0.1", "version": "1.1.2",
"description": "Package that contains all the providers of movie-web", "description": "Package that contains all the providers of movie-web",
"main": "./lib/index.umd.js", "main": "./lib/index.umd.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
@@ -80,6 +80,7 @@
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"json5": "^2.2.3", "json5": "^2.2.3",
"iso-639-1": "^3.1.0",
"nanoid": "^3.3.6", "nanoid": "^3.3.6",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"unpacker": "^1.0.1" "unpacker": "^1.0.1"

View File

@@ -0,0 +1,55 @@
import { makeStandardFetcher } from "@/fetchers/standardFetch";
import { makeProviders } from "@/main/builder";
import { targets } from "@/main/targets";
import { isValidStream } from "@/utils/valid";
import fetch from "node-fetch";
import { describe, it, expect } from "vitest";
describe('isValidStream()', () => {
it('should pass valid streams', () => {
expect(isValidStream({
type: "file",
flags: [],
qualities: {
"1080": {
type: "mp4",
url: "hello-world"
}
}
})).toBe(true);
expect(isValidStream({
type: "hls",
flags: [],
playlist: "hello-world"
})).toBe(true);
});
it('should detect empty qualities', () => {
expect(isValidStream({
type: "file",
flags: [],
qualities: {}
})).toBe(false);
});
it('should detect empty stream urls', () => {
expect(isValidStream({
type: "file",
flags: [],
qualities: {
"1080": {
type: "mp4",
url: "",
}
}
})).toBe(false);
});
it('should detect emtpy HLS playlists', () => {
expect(isValidStream({
type: "hls",
flags: [],
playlist: "",
})).toBe(false);
});
});

View File

@@ -1,5 +1,7 @@
/* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */ /* eslint import/no-extraneous-dependencies: ["error", {"devDependencies": true}] */
import util from 'node:util';
import { program } from 'commander'; import { program } from 'commander';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { prompt } from 'enquirer'; import { prompt } from 'enquirer';
@@ -45,6 +47,10 @@ if (!TMDB_API_KEY?.trim()) {
throw new Error('Missing MOVIE_WEB_TMDB_API_KEY environment variable'); throw new Error('Missing MOVIE_WEB_TMDB_API_KEY environment variable');
} }
function logDeepObject(object: Record<any, any>) {
console.log(util.inspect(object, { showHidden: false, depth: null, colors: true }));
}
function getAllSources() { function getAllSources() {
// * The only way to get a list of all sources is to // * The only way to get a list of all sources is to
// * create all these things. Maybe this should change // * create all these things. Maybe this should change
@@ -181,7 +187,7 @@ async function runScraper(providers: ProviderControls, source: MetaOutput, optio
id: source.id, id: source.id,
}); });
spinnies.succeed('scrape', { text: 'Done!' }); spinnies.succeed('scrape', { text: 'Done!' });
console.log(result); logDeepObject(result);
} catch (error) { } catch (error) {
let message = 'Unknown error'; let message = 'Unknown error';
if (error instanceof Error) { if (error instanceof Error) {
@@ -206,7 +212,7 @@ async function runScraper(providers: ProviderControls, source: MetaOutput, optio
id: source.id, id: source.id,
}); });
spinnies.succeed('scrape', { text: 'Done!' }); spinnies.succeed('scrape', { text: 'Done!' });
console.log(result); logDeepObject(result);
} catch (error) { } catch (error) {
let message = 'Unknown error'; let message = 'Unknown error';
if (error instanceof Error) { if (error instanceof Error) {

View File

@@ -6,6 +6,7 @@ import { EmbedOutput, SourcererOutput } from '@/providers/base';
import { ProviderList } from '@/providers/get'; import { ProviderList } from '@/providers/get';
import { ScrapeContext } from '@/utils/context'; import { ScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors'; import { NotFoundError } from '@/utils/errors';
import { isValidStream } from '@/utils/valid';
export type IndividualSourceRunnerOptions = { export type IndividualSourceRunnerOptions = {
features: FeatureMap; features: FeatureMap;
@@ -50,7 +51,7 @@ export async function scrapeInvidualSource(
}); });
// stream doesn't satisfy the feature flags, so gets removed in output // 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; 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)) if (!flagsAllowedInFeatures(ops.features, output.stream.flags))
throw new NotFoundError("stream doesn't satisfy target feature flags"); throw new NotFoundError("stream doesn't satisfy target feature flags");
return output; return output;
} }

View File

@@ -8,6 +8,7 @@ import { Stream } from '@/providers/streams';
import { ScrapeContext } from '@/utils/context'; import { ScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors'; import { NotFoundError } from '@/utils/errors';
import { reorderOnIdList } from '@/utils/list'; import { reorderOnIdList } from '@/utils/list';
import { isValidStream } from '@/utils/valid';
export type RunOutput = { export type RunOutput = {
sourceId: string; sourceId: string;
@@ -79,6 +80,9 @@ export async function runAllProviders(list: ProviderList, ops: ProviderRunnerOpt
...contextBase, ...contextBase,
media: ops.media, media: ops.media,
}); });
if (output?.stream && !isValidStream(output?.stream)) {
throw new NotFoundError('stream is incomplete');
}
if (output?.stream && !flagsAllowedInFeatures(ops.features, output.stream.flags)) { if (output?.stream && !flagsAllowedInFeatures(ops.features, output.stream.flags)) {
throw new NotFoundError("stream doesn't satisfy target feature flags"); throw new NotFoundError("stream doesn't satisfy target feature flags");
} }

View File

@@ -1,4 +1,5 @@
import { Embed, Sourcerer } from '@/providers/base'; import { Embed, Sourcerer } from '@/providers/base';
import { febBoxScraper } from '@/providers/embeds/febBox';
import { mixdropScraper } from '@/providers/embeds/mixdrop'; import { mixdropScraper } from '@/providers/embeds/mixdrop';
import { mp4uploadScraper } from '@/providers/embeds/mp4upload'; import { mp4uploadScraper } from '@/providers/embeds/mp4upload';
import { streamsbScraper } from '@/providers/embeds/streamsb'; import { streamsbScraper } from '@/providers/embeds/streamsb';
@@ -12,6 +13,8 @@ import { remotestreamScraper } from '@/providers/sources/remotestream';
import { superStreamScraper } from '@/providers/sources/superstream/index'; import { superStreamScraper } from '@/providers/sources/superstream/index';
import { zoechipScraper } from '@/providers/sources/zoechip'; import { zoechipScraper } from '@/providers/sources/zoechip';
import { showBoxScraper } from './sources/showbox';
export function gatherAllSources(): Array<Sourcerer> { export function gatherAllSources(): Array<Sourcerer> {
// all sources are gathered here // all sources are gathered here
return [ return [
@@ -22,10 +25,11 @@ export function gatherAllSources(): Array<Sourcerer> {
goMoviesScraper, goMoviesScraper,
zoechipScraper, zoechipScraper,
lookmovieScraper, lookmovieScraper,
showBoxScraper,
]; ];
} }
export function gatherAllEmbeds(): Array<Embed> { export function gatherAllEmbeds(): Array<Embed> {
// all embeds are gathered here // all embeds are gathered here
return [upcloudScraper, mp4uploadScraper, streamsbScraper, upstreamScraper, mixdropScraper]; return [upcloudScraper, mp4uploadScraper, streamsbScraper, upstreamScraper, febBoxScraper, mixdropScraper];
} }

32
src/providers/captions.ts Normal file
View File

@@ -0,0 +1,32 @@
import ISO6391 from 'iso-639-1';
export const captionTypes = {
srt: 'srt',
vtt: 'vtt',
};
export type CaptionType = keyof typeof captionTypes;
export type Caption = {
type: CaptionType;
url: string;
hasCorsRestrictions: boolean;
language: string;
};
export function getCaptionTypeFromUrl(url: string): CaptionType | null {
const extensions = Object.keys(captionTypes) as CaptionType[];
const type = extensions.find((v) => url.endsWith(`.${v}`));
if (!type) return null;
return type;
}
export function labelToLanguageCode(label: string): string | null {
const code = ISO6391.getCode(label);
if (code.length === 0) return null;
return code;
}
export function isValidLanguageCode(code: string | null): boolean {
if (!code) return false;
return ISO6391.validate(code);
}

View File

@@ -0,0 +1,74 @@
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<string>('/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<string, StreamFile> = {};
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',
captions: [],
flags: [flags.NO_CORS],
qualities: embedQualities,
},
};
},
});

View File

@@ -36,6 +36,7 @@ export const mixdropScraper = makeEmbed({
stream: { stream: {
type: 'file', type: 'file',
flags: [], flags: [],
captions: [],
qualities: { qualities: {
unknown: { unknown: {
type: 'mp4', type: 'mp4',

View File

@@ -18,6 +18,7 @@ export const mp4uploadScraper = makeEmbed({
stream: { stream: {
type: 'file', type: 'file',
flags: [flags.NO_CORS], flags: [flags.NO_CORS],
captions: [],
qualities: { qualities: {
'1080': { '1080': {
type: 'mp4', type: 'mp4',

View File

@@ -159,6 +159,7 @@ export const streamsbScraper = makeEmbed({
type: 'file', type: 'file',
flags: [flags.NO_CORS], flags: [flags.NO_CORS],
qualities, qualities,
captions: [],
}, },
}; };
}, },

View File

@@ -2,6 +2,7 @@ import crypto from 'crypto-js';
import { flags } from '@/main/targets'; import { flags } from '@/main/targets';
import { makeEmbed } from '@/providers/base'; import { makeEmbed } from '@/providers/base';
import { Caption, getCaptionTypeFromUrl, labelToLanguageCode } from '@/providers/captions';
const { AES, enc } = crypto; const { AES, enc } = crypto;
@@ -24,6 +25,34 @@ function isJSON(json: string) {
} }
} }
/*
example script segment:
switch(I9){case 0x0:II=X,IM=t;break;case 0x1:II=b,IM=D;break;case 0x2:II=x,IM=f;break;case 0x3:II=S,IM=j;break;case 0x4:II=U,IM=G;break;case 0x5:II=partKeyStartPosition_5,IM=partKeyLength_5;}
*/
function extractKey(script: string): [number, number][] | null {
const startOfSwitch = script.lastIndexOf('switch');
const endOfCases = script.indexOf('partKeyStartPosition');
const switchBody = script.slice(startOfSwitch, endOfCases);
const nums: [number, number][] = [];
const matches = switchBody.matchAll(/:[a-zA-Z0-9]+=([a-zA-Z0-9]+),[a-zA-Z0-9]+=([a-zA-Z0-9]+);/g);
for (const match of matches) {
const innerNumbers: number[] = [];
for (const varMatch of [match[1], match[2]]) {
const regex = new RegExp(`${varMatch}=0x([a-zA-Z0-9]+)`, 'g');
const varMatches = [...script.matchAll(regex)];
const lastMatch = varMatches[varMatches.length - 1];
if (!lastMatch) return null;
const number = parseInt(lastMatch[1], 16);
innerNumbers.push(number);
}
nums.push([innerNumbers[0], innerNumbers[1]]);
}
return nums;
}
export const upcloudScraper = makeEmbed({ export const upcloudScraper = makeEmbed({
id: 'upcloud', id: 'upcloud',
name: 'UpCloud', name: 'UpCloud',
@@ -45,20 +74,22 @@ export const upcloudScraper = makeEmbed({
let sources: { file: string; type: string } | null = null; let sources: { file: string; type: string } | null = null;
if (!isJSON(streamRes.sources)) { if (!isJSON(streamRes.sources)) {
const decryptionKey = JSON.parse( const scriptJs = await ctx.proxiedFetcher<string>(`https://rabbitstream.net/js/player/prod/e4-player.min.js`);
await ctx.proxiedFetcher<string>(`https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt`), const decryptionKey = extractKey(scriptJs);
) as [number, number][]; if (!decryptionKey) throw new Error('Key extraction failed');
let extractedKey = ''; let extractedKey = '';
const sourcesArray = streamRes.sources.split(''); let strippedSources = streamRes.sources;
for (const index of decryptionKey) { let totalledOffset = 0;
for (let i: number = index[0]; i < index[1]; i += 1) { decryptionKey.forEach(([a, b]) => {
extractedKey += streamRes.sources[i]; const start = a + totalledOffset;
sourcesArray[i] = ''; const end = start + b;
} extractedKey += streamRes.sources.slice(start, end);
} strippedSources = strippedSources.replace(streamRes.sources.substring(start, end), '');
totalledOffset += b;
});
const decryptedStream = AES.decrypt(sourcesArray.join(''), extractedKey).toString(enc.Utf8); const decryptedStream = AES.decrypt(strippedSources, extractedKey).toString(enc.Utf8);
const parsedStream = JSON.parse(decryptedStream)[0]; const parsedStream = JSON.parse(decryptedStream)[0];
if (!parsedStream) throw new Error('No stream found'); if (!parsedStream) throw new Error('No stream found');
sources = parsedStream; sources = parsedStream;
@@ -66,11 +97,27 @@ export const upcloudScraper = makeEmbed({
if (!sources) throw new Error('upcloud source not found'); if (!sources) throw new Error('upcloud source not found');
const captions: Caption[] = [];
streamRes.tracks.forEach((track) => {
if (track.kind !== 'captions') return;
const type = getCaptionTypeFromUrl(track.file);
if (!type) return;
const language = labelToLanguageCode(track.label);
if (!language) return;
captions.push({
language,
hasCorsRestrictions: false,
type,
url: track.file,
});
});
return { return {
stream: { stream: {
type: 'hls', type: 'hls',
playlist: sources.file, playlist: sources.file,
flags: [flags.NO_CORS], flags: [flags.NO_CORS],
captions,
}, },
}; };
}, },

View File

@@ -25,6 +25,7 @@ export const upstreamScraper = makeEmbed({
type: 'hls', type: 'hls',
playlist: link[1], playlist: link[1],
flags: [flags.NO_CORS], flags: [flags.NO_CORS],
captions: [],
}, },
}; };
} }

View File

@@ -1,7 +1,7 @@
import { flags } from '@/main/targets'; import { flags } from '@/main/targets';
import { makeSourcerer } from '@/providers/base'; import { makeSourcerer } from '@/providers/base';
import { upcloudScraper } from '@/providers/embeds/upcloud'; 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 { getFlixhqId } from '@/providers/sources/flixhq/search';
import { NotFoundError } from '@/utils/errors'; import { NotFoundError } from '@/utils/errors';
@@ -15,10 +15,27 @@ export const flixhqScraper = makeSourcerer({
const id = await getFlixhqId(ctx, ctx.media); const id = await getFlixhqId(ctx, ctx.media);
if (!id) throw new NotFoundError('no search results match'); 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'); const upcloudStream = sources.find((v) => v.embed.toLowerCase() === 'upcloud');
if (!upcloudStream) throw new NotFoundError('upcloud stream not found for flixhq'); 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 { return {
embeds: [ embeds: [
{ {

View File

@@ -1,16 +1,26 @@
import { load } from 'cheerio'; import { load } from 'cheerio';
import { MovieMedia, ShowMedia } from '@/main/media';
import { flixHqBase } from '@/providers/sources/flixhq/common'; import { flixHqBase } from '@/providers/sources/flixhq/common';
import { ScrapeContext } from '@/utils/context'; import { ScrapeContext } from '@/utils/context';
import { NotFoundError } from '@/utils/errors';
export async function getFlixhqSources(ctx: ScrapeContext, id: string) { export async function getFlixhqSourceDetails(ctx: ScrapeContext, sourceId: string): Promise<string> {
const type = id.split('/')[0]; const jsonData = await ctx.proxiedFetcher<Record<string, any>>(`/ajax/sources/${sourceId}`, {
baseUrl: flixHqBase,
});
return jsonData.link;
}
export async function getFlixhqMovieSources(ctx: ScrapeContext, media: MovieMedia, id: string) {
const episodeParts = id.split('-'); const episodeParts = id.split('-');
const episodeId = episodeParts[episodeParts.length - 1]; const episodeId = episodeParts[episodeParts.length - 1];
const data = await ctx.proxiedFetcher<string>(`/ajax/${type}/episodes/${episodeId}`, { const data = await ctx.proxiedFetcher<string>(`/ajax/movie/episodes/${episodeId}`, {
baseUrl: flixHqBase, baseUrl: flixHqBase,
}); });
const doc = load(data); const doc = load(data);
const sourceLinks = doc('.nav-item > a') const sourceLinks = doc('.nav-item > a')
.toArray() .toArray()
@@ -28,10 +38,55 @@ export async function getFlixhqSources(ctx: ScrapeContext, id: string) {
return sourceLinks; return sourceLinks;
} }
export async function getFlixhqSourceDetails(ctx: ScrapeContext, sourceId: string): Promise<string> { export async function getFlixhqShowSources(ctx: ScrapeContext, media: ShowMedia, id: string) {
const jsonData = await ctx.proxiedFetcher<Record<string, any>>(`/ajax/sources/${sourceId}`, { const episodeParts = id.split('-');
const episodeId = episodeParts[episodeParts.length - 1];
const seasonsListData = await ctx.proxiedFetcher<string>(`/ajax/season/list/${episodeId}`, {
baseUrl: flixHqBase, 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<string>(`/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<string>(`/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;
} }

View File

@@ -1,11 +1,11 @@
import { load } from 'cheerio'; import { load } from 'cheerio';
import { MovieMedia } from '@/main/media'; import { MovieMedia, ShowMedia } from '@/main/media';
import { flixHqBase } from '@/providers/sources/flixhq/common'; import { flixHqBase } from '@/providers/sources/flixhq/common';
import { compareMedia } from '@/utils/compare'; import { compareMedia } from '@/utils/compare';
import { ScrapeContext } from '@/utils/context'; import { ScrapeContext } from '@/utils/context';
export async function getFlixhqId(ctx: ScrapeContext, media: MovieMedia): Promise<string | null> { export async function getFlixhqId(ctx: ScrapeContext, media: MovieMedia | ShowMedia): Promise<string | null> {
const searchResults = await ctx.proxiedFetcher<string>(`/search/${media.title.replaceAll(/[^a-z0-9A-Z]/g, '-')}`, { const searchResults = await ctx.proxiedFetcher<string>(`/search/${media.title.replaceAll(/[^a-z0-9A-Z]/g, '-')}`, {
baseUrl: flixHqBase, baseUrl: flixHqBase,
}); });
@@ -23,7 +23,7 @@ export async function getFlixhqId(ctx: ScrapeContext, media: MovieMedia): Promis
return { return {
id, id,
title, title,
year: +year, year: parseInt(year, 10),
}; };
}); });

View File

@@ -17,9 +17,7 @@ export const goMoviesScraper = makeSourcerer({
async scrapeShow(ctx) { async scrapeShow(ctx) {
const search = await ctx.proxiedFetcher<string>(`/ajax/search`, { const search = await ctx.proxiedFetcher<string>(`/ajax/search`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: new URLSearchParams({ keyword: ctx.media.title }),
keyword: ctx.media.title,
}),
headers: { headers: {
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
}, },
@@ -104,9 +102,7 @@ export const goMoviesScraper = makeSourcerer({
async scrapeMovie(ctx) { async scrapeMovie(ctx) {
const search = await ctx.proxiedFetcher<string>(`ajax/search`, { const search = await ctx.proxiedFetcher<string>(`ajax/search`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: new URLSearchParams({ keyword: ctx.media.title }),
keyword: ctx.media.title,
}),
headers: { headers: {
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
}, },

View File

@@ -23,6 +23,7 @@ export const remotestreamScraper = makeSourcerer({
return { return {
embeds: [], embeds: [],
stream: { stream: {
captions: [],
playlist: playlistLink, playlist: playlistLink,
type: 'hls', type: 'hls',
flags: [flags.NO_CORS], flags: [flags.NO_CORS],
@@ -40,6 +41,7 @@ export const remotestreamScraper = makeSourcerer({
return { return {
embeds: [], embeds: [],
stream: { stream: {
captions: [],
playlist: playlistLink, playlist: playlistLink,
type: 'hls', type: 'hls',
flags: [flags.NO_CORS], flags: [flags.NO_CORS],

View File

@@ -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: 'show_box',
name: 'ShowBox',
rank: 20,
flags: [flags.NO_CORS],
async scrapeMovie(ctx) {
const search = await ctx.proxiedFetcher<string>('/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,
},
],
};
},
});

View File

@@ -3,24 +3,24 @@ import { ScrapeContext } from '@/utils/context';
import { sendRequest } from './sendRequest'; import { sendRequest } from './sendRequest';
import { allowedQualities } from '.'; const allowedQualities = ['360', '480', '720', '1080'];
export async function getStreamQualities(ctx: ScrapeContext, apiQuery: object) { export async function getStreamQualities(ctx: ScrapeContext, apiQuery: object) {
const mediaRes: { list: { path: string; real_quality: string }[] } = (await sendRequest(ctx, apiQuery)).data; const mediaRes: { list: { path: string; quality: string; fid?: number }[] } = (await sendRequest(ctx, apiQuery)).data;
ctx.progress(66); ctx.progress(66);
const qualityMap = mediaRes.list const qualityMap = mediaRes.list
.filter((file) => allowedQualities.includes(file.real_quality.replace('p', ''))) .filter((file) => allowedQualities.includes(file.quality.replace('p', '')))
.map((file) => ({ .map((file) => ({
url: file.path, url: file.path,
quality: file.real_quality.replace('p', ''), quality: file.quality.replace('p', ''),
})); }));
const qualities: Record<string, StreamFile> = {}; const qualities: Record<string, StreamFile> = {};
allowedQualities.forEach((quality) => { allowedQualities.forEach((quality) => {
const foundQuality = qualityMap.find((q) => q.quality === quality); const foundQuality = qualityMap.find((q) => q.quality === quality);
if (foundQuality) { if (foundQuality && foundQuality.url) {
qualities[quality] = { qualities[quality] = {
type: 'mp4', type: 'mp4',
url: foundQuality.url, url: foundQuality.url,
@@ -28,5 +28,8 @@ export async function getStreamQualities(ctx: ScrapeContext, apiQuery: object) {
} }
}); });
return qualities; return {
qualities,
fid: mediaRes.list[0]?.fid,
};
} }

View File

@@ -1,13 +1,12 @@
import { flags } from '@/main/targets'; import { flags } from '@/main/targets';
import { makeSourcerer } from '@/providers/base'; import { makeSourcerer } from '@/providers/base';
import { getSubtitles } from '@/providers/sources/superstream/subtitles';
import { compareTitle } from '@/utils/compare'; import { compareTitle } from '@/utils/compare';
import { NotFoundError } from '@/utils/errors'; import { NotFoundError } from '@/utils/errors';
import { getStreamQualities } from './getStreamQualities'; import { getStreamQualities } from './getStreamQualities';
import { sendRequest } from './sendRequest'; import { sendRequest } from './sendRequest';
export const allowedQualities = ['360', '480', '720', '1080'];
export const superStreamScraper = makeSourcerer({ export const superStreamScraper = makeSourcerer({
id: 'superstream', id: 'superstream',
name: 'Superstream', name: 'Superstream',
@@ -15,14 +14,14 @@ export const superStreamScraper = makeSourcerer({
flags: [flags.NO_CORS], flags: [flags.NO_CORS],
async scrapeShow(ctx) { async scrapeShow(ctx) {
const searchQuery = { const searchQuery = {
module: 'Search3', module: 'Search4',
page: '1', page: '1',
type: 'all', type: 'all',
keyword: ctx.media.title, keyword: ctx.media.title,
pagelimit: '20', pagelimit: '20',
}; };
const searchRes = (await sendRequest(ctx, searchQuery, true)).data; const searchRes = (await sendRequest(ctx, searchQuery, true)).data.list;
ctx.progress(33); ctx.progress(33);
const superstreamEntry = searchRes.find( const superstreamEntry = searchRes.find(
@@ -43,11 +42,19 @@ export const superStreamScraper = makeSourcerer({
group: '', group: '',
}; };
const qualities = await getStreamQualities(ctx, apiQuery); const { qualities, fid } = await getStreamQualities(ctx, apiQuery);
return { return {
embeds: [], embeds: [],
stream: { stream: {
captions: await getSubtitles(
ctx,
superstreamId,
fid,
'show',
ctx.media.episode.number,
ctx.media.season.number,
),
qualities, qualities,
type: 'file', type: 'file',
flags: [flags.NO_CORS], flags: [flags.NO_CORS],
@@ -56,14 +63,14 @@ export const superStreamScraper = makeSourcerer({
}, },
async scrapeMovie(ctx) { async scrapeMovie(ctx) {
const searchQuery = { const searchQuery = {
module: 'Search3', module: 'Search4',
page: '1', page: '1',
type: 'all', type: 'all',
keyword: ctx.media.title, keyword: ctx.media.title,
pagelimit: '20', pagelimit: '20',
}; };
const searchRes = (await sendRequest(ctx, searchQuery, true)).data; const searchRes = (await sendRequest(ctx, searchQuery, true)).data.list;
ctx.progress(33); ctx.progress(33);
const superstreamEntry = searchRes.find( const superstreamEntry = searchRes.find(
@@ -82,11 +89,12 @@ export const superStreamScraper = makeSourcerer({
group: '', group: '',
}; };
const qualities = await getStreamQualities(ctx, apiQuery); const { qualities, fid } = await getStreamQualities(ctx, apiQuery);
return { return {
embeds: [], embeds: [],
stream: { stream: {
captions: await getSubtitles(ctx, superstreamId, fid, 'movie'),
qualities, qualities,
type: 'file', type: 'file',
flags: [flags.NO_CORS], flags: [flags.NO_CORS],

View File

@@ -0,0 +1,58 @@
import { Caption, getCaptionTypeFromUrl, isValidLanguageCode } from '@/providers/captions';
import { sendRequest } from '@/providers/sources/superstream/sendRequest';
import { ScrapeContext } from '@/utils/context';
interface CaptionApiResponse {
data: {
list: {
subtitles: {
order: number;
lang: string;
file_path: string;
}[];
}[];
};
}
export async function getSubtitles(
ctx: ScrapeContext,
id: string,
fid: number | undefined,
type: 'show' | 'movie',
episodeId?: number,
seasonId?: number,
): Promise<Caption[]> {
const module = type === 'movie' ? 'Movie_srt_list_v2' : 'TV_srt_list_v2';
const subtitleApiQuery = {
fid,
uid: '',
module,
mid: type === 'movie' ? id : undefined,
tid: type !== 'movie' ? id : undefined,
episode: episodeId?.toString(),
season: seasonId?.toString(),
group: episodeId ? '' : undefined,
};
const subtitleList = ((await sendRequest(ctx, subtitleApiQuery)) as CaptionApiResponse).data.list;
const output: Caption[] = [];
subtitleList.forEach((sub) => {
const subtitle = sub.subtitles.sort((a, b) => b.order - a.order)[0];
if (!subtitle) return;
const subtitleType = getCaptionTypeFromUrl(subtitle.file_path);
if (!subtitleType) return;
const validCode = isValidLanguageCode(subtitle.lang);
if (!validCode) return;
output.push({
language: subtitle.lang,
hasCorsRestrictions: true,
type: subtitleType,
url: subtitle.file_path,
});
});
return output;
}

View File

@@ -1,4 +1,5 @@
import { Flags } from '@/main/targets'; import { Flags } from '@/main/targets';
import { Caption } from '@/providers/captions';
export type StreamFile = { export type StreamFile = {
type: 'mp4'; type: 'mp4';
@@ -12,12 +13,14 @@ export type FileBasedStream = {
type: 'file'; type: 'file';
flags: Flags[]; flags: Flags[];
qualities: Partial<Record<Qualities, StreamFile>>; qualities: Partial<Record<Qualities, StreamFile>>;
captions: Caption[];
}; };
export type HlsBasedStream = { export type HlsBasedStream = {
type: 'hls'; type: 'hls';
flags: Flags[]; flags: Flags[];
playlist: string; playlist: string;
captions: Caption[];
}; };
export type Stream = FileBasedStream | HlsBasedStream; export type Stream = FileBasedStream | HlsBasedStream;

View File

@@ -1,6 +1,6 @@
export class NotFoundError extends Error { export class NotFoundError extends Error {
constructor(reason?: string) { constructor(reason?: string) {
super(`Couldn't found a stream: ${reason ?? 'not found'}`); super(`Couldn't find a stream: ${reason ?? 'not found'}`);
this.name = 'NotFoundError'; this.name = 'NotFoundError';
} }
} }

17
src/utils/valid.ts Normal file
View File

@@ -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;
}