diff --git a/.docs/content/2.Api/3.ProviderControlsrunEmbedScraper.md b/.docs/content/2.Api/3.ProviderControlsrunEmbedScraper.md index 4a877df..bfdd0b2 100644 --- a/.docs/content/2.Api/3.ProviderControlsrunEmbedScraper.md +++ b/.docs/content/2.Api/3.ProviderControlsrunEmbedScraper.md @@ -10,7 +10,7 @@ import { SourcererOutput } from "@movie-web/providers"; // scrape a stream from upcloud let output: EmbedOutput; try { - output = await providers.runSourceScraper({ + output = await providers.runEmbedScraper({ id: 'upcloud', url: 'https://example.com/123', }) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c2b7c4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 movie-web + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/providers/all.ts b/src/providers/all.ts index 533b1ba..c2eb771 100644 --- a/src/providers/all.ts +++ b/src/providers/all.ts @@ -1,5 +1,6 @@ import { Embed, Sourcerer } from '@/providers/base'; -import { febBoxScraper } from '@/providers/embeds/febBox'; +import { febboxHlsScraper } from '@/providers/embeds/febbox/hls'; +import { febboxMp4Scraper } from '@/providers/embeds/febbox/mp4'; import { mixdropScraper } from '@/providers/embeds/mixdrop'; import { mp4uploadScraper } from '@/providers/embeds/mp4upload'; import { streambucketScraper } from '@/providers/embeds/streambucket'; @@ -12,13 +13,12 @@ import { goMoviesScraper } from '@/providers/sources/gomovies/index'; import { kissAsianScraper } from '@/providers/sources/kissasian/index'; import { lookmovieScraper } from '@/providers/sources/lookmovie'; import { remotestreamScraper } from '@/providers/sources/remotestream'; -import { superStreamScraper } from '@/providers/sources/superstream/index'; -import { vidsrcScraper } from '@/providers/sources/vidsrc'; +import { showboxScraper } from '@/providers/sources/showbox/index'; +import { vidsrcScraper } from '@/providers/sources/vidsrc/index'; import { zoechipScraper } from '@/providers/sources/zoechip'; import { smashyStreamDScraper } from './embeds/smashystream/dued'; import { smashyStreamFScraper } from './embeds/smashystream/video1'; -import { showBoxScraper } from './sources/showbox'; import { smashyStreamScraper } from './sources/smashystream'; export function gatherAllSources(): Array { @@ -27,12 +27,11 @@ export function gatherAllSources(): Array { flixhqScraper, remotestreamScraper, kissAsianScraper, - superStreamScraper, + showboxScraper, goMoviesScraper, zoechipScraper, vidsrcScraper, lookmovieScraper, - showBoxScraper, smashyStreamScraper, ]; } @@ -44,10 +43,11 @@ export function gatherAllEmbeds(): Array { mp4uploadScraper, streamsbScraper, upstreamScraper, + febboxMp4Scraper, + febboxHlsScraper, mixdropScraper, vidsrcembedScraper, streambucketScraper, - febBoxScraper, smashyStreamFScraper, smashyStreamDScraper, ]; diff --git a/src/providers/embeds/febBox.ts b/src/providers/embeds/febBox.ts deleted file mode 100644 index 7855745..0000000 --- a/src/providers/embeds/febBox.ts +++ /dev/null @@ -1,74 +0,0 @@ -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', - captions: [], - flags: [flags.NO_CORS], - qualities: embedQualities, - }, - }; - }, -}); diff --git a/src/providers/embeds/febbox/common.ts b/src/providers/embeds/febbox/common.ts new file mode 100644 index 0000000..4348c25 --- /dev/null +++ b/src/providers/embeds/febbox/common.ts @@ -0,0 +1,24 @@ +import { MediaTypes } from '@/main/media'; + +export const febBoxBase = `https://www.febbox.com`; + +export interface FebboxFileList { + file_name: string; + ext: string; + fid: number; + oss_fid: number; + is_dir: 0 | 1; +} + +export function parseInputUrl(url: string) { + const [type, id, seasonId, episodeId] = url.slice(1).split('/'); + const season = seasonId ? parseInt(seasonId, 10) : undefined; + const episode = episodeId ? parseInt(episodeId, 10) : undefined; + + return { + type: type as MediaTypes, + id, + season, + episode, + }; +} diff --git a/src/providers/embeds/febbox/fileList.ts b/src/providers/embeds/febbox/fileList.ts new file mode 100644 index 0000000..b0c03fb --- /dev/null +++ b/src/providers/embeds/febbox/fileList.ts @@ -0,0 +1,69 @@ +import { MediaTypes } from '@/main/media'; +import { FebboxFileList, febBoxBase } from '@/providers/embeds/febbox/common'; +import { EmbedScrapeContext } from '@/utils/context'; + +export async function getFileList( + ctx: EmbedScrapeContext, + shareKey: string, + parentId?: number, +): Promise { + const query: Record = { + share_key: shareKey, + pwd: '', + }; + if (parentId) { + query.parent_id = parentId.toString(); + query.page = '1'; + } + + const streams = await ctx.proxiedFetcher<{ + data?: { + file_list?: FebboxFileList[]; + }; + }>('/file/file_share_list', { + headers: { + 'accept-language': 'en', // without this header, the request is marked as a webscraper + }, + baseUrl: febBoxBase, + query, + }); + + return streams.data?.file_list ?? []; +} + +function isValidStream(file: FebboxFileList): boolean { + return file.ext === 'mp4' || file.ext === 'mkv'; +} + +export async function getStreams( + ctx: EmbedScrapeContext, + shareKey: string, + type: MediaTypes, + season?: number, + episode?: number, +): Promise { + const streams = await getFileList(ctx, shareKey); + + if (type === 'show') { + const seasonFolder = streams.find((v) => { + if (!v.is_dir) return false; + return v.file_name.toLowerCase() === `season ${season}`; + }); + if (!seasonFolder) return []; + + const episodes = await getFileList(ctx, shareKey, seasonFolder.fid); + const s = season?.toString() ?? '0'; + const e = episode?.toString() ?? '0'; + const episodeRegex = new RegExp(`[Ss]0*${s}[Ee]0*${e}`); + return episodes + .filter((file) => { + if (file.is_dir) return false; + const match = file.file_name.match(episodeRegex); + if (!match) return false; + return true; + }) + .filter(isValidStream); + } + + return streams.filter((v) => !v.is_dir).filter(isValidStream); +} diff --git a/src/providers/embeds/febbox/hls.ts b/src/providers/embeds/febbox/hls.ts new file mode 100644 index 0000000..58478ca --- /dev/null +++ b/src/providers/embeds/febbox/hls.ts @@ -0,0 +1,47 @@ +import { MediaTypes } from '@/main/media'; +import { flags } from '@/main/targets'; +import { makeEmbed } from '@/providers/base'; +import { parseInputUrl } from '@/providers/embeds/febbox/common'; +import { getStreams } from '@/providers/embeds/febbox/fileList'; +import { getSubtitles } from '@/providers/embeds/febbox/subtitles'; +import { showboxBase } from '@/providers/sources/showbox/common'; + +// structure: https://www.febbox.com/share/ +export function extractShareKey(url: string): string { + const parsedUrl = new URL(url); + const shareKey = parsedUrl.pathname.split('/')[2]; + return shareKey; +} +export const febboxHlsScraper = makeEmbed({ + id: 'febbox-hls', + name: 'Febbox (HLS)', + rank: 160, + async scrape(ctx) { + const { type, id, season, episode } = parseInputUrl(ctx.url); + const sharelinkResult = await ctx.proxiedFetcher<{ + data?: { link?: string }; + }>('/index/share_link', { + baseUrl: showboxBase, + query: { + id, + type: type === 'movie' ? '1' : '2', + }, + }); + if (!sharelinkResult?.data?.link) throw new Error('No embed url found'); + ctx.progress(30); + const shareKey = extractShareKey(sharelinkResult.data.link); + const fileList = await getStreams(ctx, shareKey, type, season, episode); + const firstStream = fileList[0]; + if (!firstStream) throw new Error('No playable mp4 stream found'); + ctx.progress(70); + + return { + stream: { + type: 'hls', + flags: [flags.NO_CORS], + captions: await getSubtitles(ctx, id, firstStream.fid, type as MediaTypes, season, episode), + playlist: `https://www.febbox.com/hls/main/${firstStream.oss_fid}.m3u8`, + }, + }; + }, +}); diff --git a/src/providers/embeds/febbox/mp4.ts b/src/providers/embeds/febbox/mp4.ts new file mode 100644 index 0000000..086f191 --- /dev/null +++ b/src/providers/embeds/febbox/mp4.ts @@ -0,0 +1,50 @@ +import { flags } from '@/main/targets'; +import { makeEmbed } from '@/providers/base'; +import { parseInputUrl } from '@/providers/embeds/febbox/common'; +import { getStreamQualities } from '@/providers/embeds/febbox/qualities'; +import { getSubtitles } from '@/providers/embeds/febbox/subtitles'; + +export const febboxMp4Scraper = makeEmbed({ + id: 'febbox-mp4', + name: 'Febbox (MP4)', + rank: 190, + async scrape(ctx) { + const { type, id, season, episode } = parseInputUrl(ctx.url); + let apiQuery: object | null = null; + + if (type === 'movie') { + apiQuery = { + uid: '', + module: 'Movie_downloadurl_v3', + mid: id, + oss: '1', + group: '', + }; + } else if (type === 'show') { + apiQuery = { + uid: '', + module: 'TV_downloadurl_v3', + tid: id, + season, + episode, + oss: '1', + group: '', + }; + } + + if (!apiQuery) throw Error('Incorrect type'); + + const { qualities, fid } = await getStreamQualities(ctx, apiQuery); + if (fid === undefined) throw new Error('No streamable file found'); + ctx.progress(70); + + return { + stream: { + captions: await getSubtitles(ctx, id, fid, type, episode, season), + qualities, + type: 'file', + flags: [flags.NO_CORS], + }, + }; + }, +}); diff --git a/src/providers/sources/superstream/getStreamQualities.ts b/src/providers/embeds/febbox/qualities.ts similarity index 87% rename from src/providers/sources/superstream/getStreamQualities.ts rename to src/providers/embeds/febbox/qualities.ts index 5e82b4c..54f8866 100644 --- a/src/providers/sources/superstream/getStreamQualities.ts +++ b/src/providers/embeds/febbox/qualities.ts @@ -1,13 +1,11 @@ +import { sendRequest } from '@/providers/sources/showbox/sendRequest'; import { StreamFile } from '@/providers/streams'; import { ScrapeContext } from '@/utils/context'; -import { sendRequest } from './sendRequest'; - -const allowedQualities = ['360', '480', '720', '1080']; +const allowedQualities = ['360', '480', '720', '1080', '4k']; export async function getStreamQualities(ctx: ScrapeContext, apiQuery: object) { const mediaRes: { list: { path: string; quality: string; fid?: number }[] } = (await sendRequest(ctx, apiQuery)).data; - ctx.progress(66); const qualityMap = mediaRes.list .filter((file) => allowedQualities.includes(file.quality.replace('p', ''))) diff --git a/src/providers/sources/superstream/subtitles.ts b/src/providers/embeds/febbox/subtitles.ts similarity index 92% rename from src/providers/sources/superstream/subtitles.ts rename to src/providers/embeds/febbox/subtitles.ts index 36be8de..a0394ec 100644 --- a/src/providers/sources/superstream/subtitles.ts +++ b/src/providers/embeds/febbox/subtitles.ts @@ -1,9 +1,8 @@ import { Caption, getCaptionTypeFromUrl, isValidLanguageCode } from '@/providers/captions'; -import { sendRequest } from '@/providers/sources/superstream/sendRequest'; +import { captionsDomains } from '@/providers/sources/showbox/common'; +import { sendRequest } from '@/providers/sources/showbox/sendRequest'; import { ScrapeContext } from '@/utils/context'; -import { captionsDomains } from './common'; - interface CaptionApiResponse { data: { list: { diff --git a/src/providers/sources/superstream/LICENSE b/src/providers/sources/showbox/LICENSE similarity index 100% rename from src/providers/sources/superstream/LICENSE rename to src/providers/sources/showbox/LICENSE diff --git a/src/providers/sources/superstream/common.ts b/src/providers/sources/showbox/common.ts similarity index 93% rename from src/providers/sources/superstream/common.ts rename to src/providers/sources/showbox/common.ts index 6ad1448..b2cf855 100644 --- a/src/providers/sources/superstream/common.ts +++ b/src/providers/sources/showbox/common.ts @@ -12,3 +12,5 @@ export const apiUrls = [ export const appKey = atob('bW92aWVib3g='); export const appId = atob('Y29tLnRkby5zaG93Ym94'); export const captionsDomains = [atob('bWJwaW1hZ2VzLmNodWF4aW4uY29t'), atob('aW1hZ2VzLnNoZWd1Lm5ldA==')]; + +export const showboxBase = 'https://www.showbox.media'; diff --git a/src/providers/sources/superstream/crypto.ts b/src/providers/sources/showbox/crypto.ts similarity index 100% rename from src/providers/sources/superstream/crypto.ts rename to src/providers/sources/showbox/crypto.ts diff --git a/src/providers/sources/showbox/index.ts b/src/providers/sources/showbox/index.ts index 0a2ddbb..94164ee 100644 --- a/src/providers/sources/showbox/index.ts +++ b/src/providers/sources/showbox/index.ts @@ -1,64 +1,53 @@ -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 { SourcererOutput, makeSourcerer } from '@/providers/base'; +import { febboxHlsScraper } from '@/providers/embeds/febbox/hls'; +import { febboxMp4Scraper } from '@/providers/embeds/febbox/mp4'; +import { compareTitle } from '@/utils/compare'; +import { MovieScrapeContext, ShowScrapeContext } from '@/utils/context'; import { NotFoundError } from '@/utils/errors'; -const showboxBase = `https://www.showbox.media`; +import { sendRequest } from './sendRequest'; -export const showBoxScraper = makeSourcerer({ - id: 'show_box', - name: 'ShowBox', - rank: 20, - disabled: true, +async function comboScraper(ctx: ShowScrapeContext | MovieScrapeContext): Promise { + const searchQuery = { + module: 'Search4', + page: '1', + type: 'all', + keyword: ctx.media.title, + pagelimit: '20', + }; + + const searchRes = (await sendRequest(ctx, searchQuery, true)).data.list; + ctx.progress(50); + + const showboxEntry = searchRes.find( + (res: any) => compareTitle(res.title, ctx.media.title) && res.year === Number(ctx.media.releaseYear), + ); + if (!showboxEntry) throw new NotFoundError('No entry found'); + + const id = showboxEntry.id; + const season = ctx.media.type === 'show' ? ctx.media.season.number : ''; + const episode = ctx.media.type === 'show' ? ctx.media.episode.number : ''; + + return { + embeds: [ + { + embedId: febboxHlsScraper.id, + url: `/${ctx.media.type}/${id}/${season}/${episode}`, + }, + { + embedId: febboxMp4Scraper.id, + url: `/${ctx.media.type}/${id}/${season}/${episode}`, + }, + ], + }; +} + +export const showboxScraper = makeSourcerer({ + id: 'showbox', + name: 'Showbox', + rank: 300, 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, - }, - ], - }; - }, + scrapeShow: comboScraper, + scrapeMovie: comboScraper, }); diff --git a/src/providers/sources/superstream/sendRequest.ts b/src/providers/sources/showbox/sendRequest.ts similarity index 100% rename from src/providers/sources/superstream/sendRequest.ts rename to src/providers/sources/showbox/sendRequest.ts diff --git a/src/providers/sources/superstream/index.ts b/src/providers/sources/superstream/index.ts deleted file mode 100644 index 173f849..0000000 --- a/src/providers/sources/superstream/index.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { flags } from '@/main/targets'; -import { makeSourcerer } from '@/providers/base'; -import { getSubtitles } from '@/providers/sources/superstream/subtitles'; -import { compareTitle } from '@/utils/compare'; -import { NotFoundError } from '@/utils/errors'; - -import { getStreamQualities } from './getStreamQualities'; -import { sendRequest } from './sendRequest'; - -export const superStreamScraper = makeSourcerer({ - id: 'superstream', - name: 'Superstream', - rank: 300, - flags: [flags.NO_CORS], - async scrapeShow(ctx) { - const searchQuery = { - module: 'Search4', - page: '1', - type: 'all', - keyword: ctx.media.title, - pagelimit: '20', - }; - - const searchRes = (await sendRequest(ctx, searchQuery, true)).data.list; - ctx.progress(33); - - const superstreamEntry = searchRes.find( - (res: any) => compareTitle(res.title, ctx.media.title) && res.year === Number(ctx.media.releaseYear), - ); - - if (!superstreamEntry) throw new NotFoundError('No entry found'); - const superstreamId = superstreamEntry.id; - - // Fetch requested episode - const apiQuery = { - uid: '', - module: 'TV_downloadurl_v3', - tid: superstreamId, - season: ctx.media.season.number, - episode: ctx.media.episode.number, - oss: '1', - group: '', - }; - - const { qualities, fid } = await getStreamQualities(ctx, apiQuery); - if (fid === undefined) throw new NotFoundError('No streamable file found'); - - return { - embeds: [], - stream: { - captions: await getSubtitles( - ctx, - superstreamId, - fid, - 'show', - ctx.media.episode.number, - ctx.media.season.number, - ), - qualities, - type: 'file', - flags: [flags.NO_CORS], - }, - }; - }, - async scrapeMovie(ctx) { - const searchQuery = { - module: 'Search4', - page: '1', - type: 'all', - keyword: ctx.media.title, - pagelimit: '20', - }; - - const searchRes = (await sendRequest(ctx, searchQuery, true)).data.list; - ctx.progress(33); - - const superstreamEntry = searchRes.find( - (res: any) => compareTitle(res.title, ctx.media.title) && res.year === Number(ctx.media.releaseYear), - ); - - if (!superstreamEntry) throw new NotFoundError('No entry found'); - const superstreamId = superstreamEntry.id; - - // Fetch requested episode - const apiQuery = { - uid: '', - module: 'Movie_downloadurl_v3', - mid: superstreamId, - oss: '1', - group: '', - }; - - const { qualities, fid } = await getStreamQualities(ctx, apiQuery); - if (fid === undefined) throw new NotFoundError('No streamable file found'); - - return { - embeds: [], - stream: { - captions: await getSubtitles(ctx, superstreamId, fid, 'movie'), - qualities, - type: 'file', - flags: [flags.NO_CORS], - }, - }; - }, -}); diff --git a/src/providers/streams.ts b/src/providers/streams.ts index 34863dd..1ba4c9a 100644 --- a/src/providers/streams.ts +++ b/src/providers/streams.ts @@ -7,7 +7,7 @@ export type StreamFile = { headers?: Record; }; -export type Qualities = 'unknown' | '360' | '480' | '720' | '1080'; +export type Qualities = 'unknown' | '360' | '480' | '720' | '1080' | '4k'; export type FileBasedStream = { type: 'file';