196 changed files with 11158 additions and 5142 deletions
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
root = true |
||||
|
||||
[*] |
||||
end_of_line = lf |
||||
insert_final_newline = true |
||||
indent_size = 2 |
||||
indent_style = space |
||||
@ -1,7 +1,5 @@
@@ -1,7 +1,5 @@
|
||||
{ |
||||
"files.eol": "\n", |
||||
"editor.detectIndentation": false, |
||||
"editor.tabSize": 2, |
||||
"editor.formatOnSave": true, |
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint", |
||||
"eslint.format.enable": true |
||||
} |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
module.exports = { |
||||
trailingComma: "all", |
||||
singleQuote: true |
||||
}; |
||||
@ -1,50 +0,0 @@
@@ -1,50 +0,0 @@
|
||||
{ |
||||
"global": { |
||||
"name": "movie-web" |
||||
}, |
||||
"search": { |
||||
"loading": "Fetching your favourite shows...", |
||||
"providersFailed": "{{fails}}/{{total}} providers failed!", |
||||
"allResults": "That's all we have!", |
||||
"noResults": "We couldn't find anything!", |
||||
"allFailed": "All providers have failed!", |
||||
"headingTitle": "Search results", |
||||
"headingLink": "Back to home", |
||||
"bookmarks": "Bookmarks", |
||||
"continueWatching": "Continue Watching", |
||||
"tagline": "Because watching legally is boring", |
||||
"title": "What do you want to watch?", |
||||
"placeholder": "What do you want to watch?" |
||||
}, |
||||
"media": { |
||||
"invalidUrl": "Your URL may be invalid", |
||||
"arrowText": "Go back" |
||||
}, |
||||
"seasons": { |
||||
"season": "Season {{season}}", |
||||
"failed": "Failed to get season data" |
||||
}, |
||||
"notFound": { |
||||
"backArrow": "Back to home", |
||||
"media": { |
||||
"title": "Couldn't find that media", |
||||
"description": "We couldn't find the media you requested. Either it's been removed or you tampered with the URL" |
||||
}, |
||||
"provider": { |
||||
"title": "This provider has been disabled", |
||||
"description": "We had issues with the provider or it was too unstable to use, so we had to disable it." |
||||
}, |
||||
"page": { |
||||
"title": "Couldn't find that page", |
||||
"description": "We looked everywhere: under the bins, in the closet, behind the proxy but ultimately couldn't find the page you are looking for." |
||||
} |
||||
}, |
||||
"searchBar": { |
||||
"movie": "Movie", |
||||
"series": "Series", |
||||
"Search": "Search" |
||||
}, |
||||
"errorBoundary": { |
||||
"text": "The app encountered an error and wasn't able to recover, please report it to the" |
||||
} |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed"; |
||||
import { registerEmbedScraper } from "@/backend/helpers/register"; |
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
||||
|
||||
registerEmbedScraper({ |
||||
id: "playm4u", |
||||
displayName: "playm4u", |
||||
for: MWEmbedType.PLAYM4U, |
||||
rank: 0, |
||||
async getStream() { |
||||
// throw new Error("Oh well 2")
|
||||
return { |
||||
streamUrl: "", |
||||
quality: MWStreamQuality.Q1080P, |
||||
captions: [], |
||||
type: MWStreamType.MP4, |
||||
}; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed"; |
||||
import { registerEmbedScraper } from "@/backend/helpers/register"; |
||||
import { |
||||
MWStreamQuality, |
||||
MWStreamType, |
||||
MWStream, |
||||
} from "@/backend/helpers/streams"; |
||||
import { proxiedFetch } from "@/backend/helpers/fetch"; |
||||
|
||||
const HOST = "streamm4u.club"; |
||||
const URL_BASE = `https://${HOST}`; |
||||
const URL_API = `${URL_BASE}/api`; |
||||
const URL_API_SOURCE = `${URL_API}/source`; |
||||
|
||||
async function scrape(embed: string) { |
||||
const sources: MWStream[] = []; |
||||
|
||||
const embedID = embed.split("/").pop(); |
||||
|
||||
console.log(`${URL_API_SOURCE}/${embedID}`); |
||||
const json = await proxiedFetch<any>(`${URL_API_SOURCE}/${embedID}`, { |
||||
method: "POST", |
||||
body: `r=&d=${HOST}`, |
||||
}); |
||||
|
||||
if (json.success) { |
||||
const streams = json.data; |
||||
|
||||
for (const stream of streams) { |
||||
sources.push({ |
||||
streamUrl: stream.file as string, |
||||
quality: stream.label as MWStreamQuality, |
||||
type: stream.type as MWStreamType, |
||||
captions: [], |
||||
}); |
||||
} |
||||
} |
||||
|
||||
return sources; |
||||
} |
||||
|
||||
// TODO check out 403 / 404 on successfully returned video stream URLs
|
||||
registerEmbedScraper({ |
||||
id: "streamm4u", |
||||
displayName: "streamm4u", |
||||
for: MWEmbedType.STREAMM4U, |
||||
rank: 100, |
||||
async getStream({ progress, url }) { |
||||
// const scrapingThreads = [];
|
||||
// const streams = [];
|
||||
|
||||
const sources = (await scrape(url)).sort( |
||||
(a, b) => |
||||
Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", "")) |
||||
); |
||||
// const preferredSourceIndex = 0;
|
||||
const preferredSource = sources[0]; |
||||
|
||||
if (!preferredSource) throw new Error("No source found"); |
||||
|
||||
progress(100); |
||||
|
||||
return preferredSource; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; |
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; |
||||
import toWebVTT from "srt-webvtt"; |
||||
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> { |
||||
if (caption.type === MWCaptionType.SRT) { |
||||
let captionBlob: Blob; |
||||
|
||||
if (caption.needsProxy) { |
||||
captionBlob = await proxiedFetch<Blob>(caption.url, { |
||||
responseType: "blob" as any, |
||||
}); |
||||
} else { |
||||
captionBlob = await mwFetch<Blob>(caption.url, { |
||||
responseType: "blob" as any, |
||||
}); |
||||
} |
||||
|
||||
return toWebVTT(captionBlob); |
||||
} |
||||
|
||||
if (caption.type === MWCaptionType.VTT) { |
||||
if (caption.needsProxy) { |
||||
const blob = await proxiedFetch<Blob>(caption.url, { |
||||
responseType: "blob" as any, |
||||
}); |
||||
return URL.createObjectURL(blob); |
||||
} |
||||
|
||||
return caption.url; |
||||
} |
||||
|
||||
throw new Error("invalid type"); |
||||
} |
||||
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
import { MWStream } from "./streams"; |
||||
|
||||
export enum MWEmbedType { |
||||
M4UFREE = "m4ufree", |
||||
STREAMM4U = "streamm4u", |
||||
PLAYM4U = "playm4u", |
||||
} |
||||
|
||||
export type MWEmbed = { |
||||
type: MWEmbedType; |
||||
url: string; |
||||
}; |
||||
|
||||
export type MWEmbedContext = { |
||||
progress(percentage: number): void; |
||||
url: string; |
||||
}; |
||||
|
||||
export type MWEmbedScraper = { |
||||
id: string; |
||||
displayName: string; |
||||
for: MWEmbedType; |
||||
rank: number; |
||||
disabled?: boolean; |
||||
|
||||
getStream(ctx: MWEmbedContext): Promise<MWStream>; |
||||
}; |
||||
@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
|
||||
import { conf } from "@/setup/config"; |
||||
import { ofetch } from "ofetch"; |
||||
|
||||
type P<T> = Parameters<typeof ofetch<T>>; |
||||
type R<T> = ReturnType<typeof ofetch<T>>; |
||||
|
||||
const baseFetch = ofetch.create({ |
||||
retry: 0, |
||||
}); |
||||
|
||||
export function makeUrl(url: string, data: Record<string, string>) { |
||||
let parsedUrl: string = url; |
||||
Object.entries(data).forEach(([k, v]) => { |
||||
parsedUrl = parsedUrl.replace(`{${k}}`, encodeURIComponent(v)); |
||||
}); |
||||
return parsedUrl; |
||||
} |
||||
|
||||
export function mwFetch<T>(url: string, ops: P<T>[1] = {}): R<T> { |
||||
return baseFetch<T>(url, ops); |
||||
} |
||||
|
||||
export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> { |
||||
let combinedUrl = ops?.baseURL ?? ""; |
||||
if ( |
||||
combinedUrl.length > 0 && |
||||
combinedUrl.endsWith("/") && |
||||
url.startsWith("/") |
||||
) |
||||
combinedUrl += url.slice(1); |
||||
else if ( |
||||
combinedUrl.length > 0 && |
||||
!combinedUrl.endsWith("/") && |
||||
!url.startsWith("/") |
||||
) |
||||
combinedUrl += `/${url}`; |
||||
else combinedUrl += url; |
||||
|
||||
const parsedUrl = new URL(combinedUrl); |
||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => { |
||||
parsedUrl.searchParams.set(k, v); |
||||
}); |
||||
|
||||
return baseFetch<T>(conf().BASE_PROXY_URL, { |
||||
...ops, |
||||
baseURL: undefined, |
||||
params: { |
||||
destination: parsedUrl.toString(), |
||||
}, |
||||
}); |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
import { DetailedMeta } from "../metadata/getmeta"; |
||||
import { MWMediaType } from "../metadata/types"; |
||||
import { MWEmbed } from "./embed"; |
||||
import { MWStream } from "./streams"; |
||||
|
||||
export type MWProviderScrapeResult = { |
||||
stream?: MWStream; |
||||
embeds: MWEmbed[]; |
||||
}; |
||||
|
||||
type MWProviderBase = { |
||||
progress(percentage: number): void; |
||||
media: DetailedMeta; |
||||
}; |
||||
type MWProviderTypeSpecific = |
||||
| { |
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME; |
||||
episode?: undefined; |
||||
season?: undefined; |
||||
} |
||||
| { |
||||
type: MWMediaType.SERIES; |
||||
episode: string; |
||||
season: string; |
||||
}; |
||||
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase; |
||||
|
||||
export type MWProvider = { |
||||
id: string; |
||||
displayName: string; |
||||
rank: number; |
||||
disabled?: boolean; |
||||
type: MWMediaType[]; |
||||
|
||||
scrape(ctx: MWProviderContext): Promise<MWProviderScrapeResult>; |
||||
}; |
||||
@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
|
||||
import { MWEmbedScraper, MWEmbedType } from "./embed"; |
||||
import { MWProvider } from "./provider"; |
||||
|
||||
let providers: MWProvider[] = []; |
||||
let embeds: MWEmbedScraper[] = []; |
||||
|
||||
export function registerProvider(provider: MWProvider) { |
||||
if (provider.disabled) return; |
||||
providers.push(provider); |
||||
} |
||||
export function registerEmbedScraper(embed: MWEmbedScraper) { |
||||
if (embed.disabled) return; |
||||
embeds.push(embed); |
||||
} |
||||
|
||||
export function initializeScraperStore() { |
||||
// sort by ranking
|
||||
providers = providers.sort((a, b) => b.rank - a.rank); |
||||
embeds = embeds.sort((a, b) => b.rank - a.rank); |
||||
|
||||
// check for invalid ranks
|
||||
let lastRank: null | number = null; |
||||
providers.forEach((v) => { |
||||
if (lastRank === null) { |
||||
lastRank = v.rank; |
||||
return; |
||||
} |
||||
if (lastRank === v.rank) |
||||
throw new Error(`Duplicate rank number for provider ${v.id}`); |
||||
lastRank = v.rank; |
||||
}); |
||||
lastRank = null; |
||||
providers.forEach((v) => { |
||||
if (lastRank === null) { |
||||
lastRank = v.rank; |
||||
return; |
||||
} |
||||
if (lastRank === v.rank) |
||||
throw new Error(`Duplicate rank number for embed scraper ${v.id}`); |
||||
lastRank = v.rank; |
||||
}); |
||||
|
||||
// check for duplicate ids
|
||||
const providerIds = providers.map((v) => v.id); |
||||
if ( |
||||
providerIds.length > 0 && |
||||
new Set(providerIds).size !== providerIds.length |
||||
) |
||||
throw new Error("Duplicate IDS in providers"); |
||||
const embedIds = embeds.map((v) => v.id); |
||||
if (embedIds.length > 0 && new Set(embedIds).size !== embedIds.length) |
||||
throw new Error("Duplicate IDS in embed scrapers"); |
||||
|
||||
// check for duplicate embed types
|
||||
const embedTypes = embeds.map((v) => v.for); |
||||
if (embedTypes.length > 0 && new Set(embedTypes).size !== embedTypes.length) |
||||
throw new Error("Duplicate types in embed scrapers"); |
||||
} |
||||
|
||||
export function getProviders(): MWProvider[] { |
||||
return providers; |
||||
} |
||||
|
||||
export function getEmbeds(): MWEmbedScraper[] { |
||||
return embeds; |
||||
} |
||||
|
||||
export function getEmbedScraperByType( |
||||
type: MWEmbedType |
||||
): MWEmbedScraper | null { |
||||
return getEmbeds().find((v) => v.for === type) ?? null; |
||||
} |
||||
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
import { MWEmbed, MWEmbedContext, MWEmbedScraper } from "./embed"; |
||||
import { |
||||
MWProvider, |
||||
MWProviderContext, |
||||
MWProviderScrapeResult, |
||||
} from "./provider"; |
||||
import { getEmbedScraperByType } from "./register"; |
||||
import { MWStream } from "./streams"; |
||||
|
||||
function sortProviderResult( |
||||
ctx: MWProviderScrapeResult |
||||
): MWProviderScrapeResult { |
||||
ctx.embeds = ctx.embeds |
||||
.map<[MWEmbed, MWEmbedScraper | null]>((v) => [ |
||||
v, |
||||
v.type ? getEmbedScraperByType(v.type) : null, |
||||
]) |
||||
.sort(([, a], [, b]) => (b?.rank ?? 0) - (a?.rank ?? 0)) |
||||
.map((v) => v[0]); |
||||
return ctx; |
||||
} |
||||
|
||||
export async function runProvider( |
||||
provider: MWProvider, |
||||
ctx: MWProviderContext |
||||
): Promise<MWProviderScrapeResult> { |
||||
try { |
||||
const data = await provider.scrape(ctx); |
||||
return sortProviderResult(data); |
||||
} catch (err) { |
||||
console.error("Failed to run provider", err, { |
||||
id: provider.id, |
||||
ctx: { ...ctx }, |
||||
}); |
||||
throw err; |
||||
} |
||||
} |
||||
|
||||
export async function runEmbedScraper( |
||||
scraper: MWEmbedScraper, |
||||
ctx: MWEmbedContext |
||||
): Promise<MWStream> { |
||||
try { |
||||
return await scraper.getStream(ctx); |
||||
} catch (err) { |
||||
console.error("Failed to run embed scraper", { |
||||
id: scraper.id, |
||||
ctx: { ...ctx }, |
||||
}); |
||||
throw err; |
||||
} |
||||
} |
||||
@ -0,0 +1,166 @@
@@ -0,0 +1,166 @@
|
||||
import { MWProviderContext, MWProviderScrapeResult } from "./provider"; |
||||
import { getEmbedScraperByType, getProviders } from "./register"; |
||||
import { runEmbedScraper, runProvider } from "./run"; |
||||
import { MWStream } from "./streams"; |
||||
import { DetailedMeta } from "../metadata/getmeta"; |
||||
import { MWMediaType } from "../metadata/types"; |
||||
|
||||
interface MWProgressData { |
||||
type: "embed" | "provider"; |
||||
id: string; |
||||
eventId: string; |
||||
percentage: number; |
||||
errored: boolean; |
||||
} |
||||
interface MWNextData { |
||||
id: string; |
||||
eventId: string; |
||||
type: "embed" | "provider"; |
||||
} |
||||
|
||||
type MWProviderRunContextBase = { |
||||
media: DetailedMeta; |
||||
onProgress?: (data: MWProgressData) => void; |
||||
onNext?: (data: MWNextData) => void; |
||||
}; |
||||
type MWProviderRunContextTypeSpecific = |
||||
| { |
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME; |
||||
episode: undefined; |
||||
season: undefined; |
||||
} |
||||
| { |
||||
type: MWMediaType.SERIES; |
||||
episode: string; |
||||
season: string; |
||||
}; |
||||
|
||||
export type MWProviderRunContext = MWProviderRunContextBase & |
||||
MWProviderRunContextTypeSpecific; |
||||
|
||||
async function findBestEmbedStream( |
||||
result: MWProviderScrapeResult, |
||||
providerId: string, |
||||
ctx: MWProviderRunContext |
||||
): Promise<MWStream | null> { |
||||
if (result.stream) return result.stream; |
||||
|
||||
let embedNum = 0; |
||||
for (const embed of result.embeds) { |
||||
embedNum += 1; |
||||
if (!embed.type) continue; |
||||
const scraper = getEmbedScraperByType(embed.type); |
||||
if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`); |
||||
|
||||
const eventId = [providerId, scraper.id, embedNum].join("|"); |
||||
|
||||
ctx.onNext?.({ id: scraper.id, type: "embed", eventId }); |
||||
|
||||
let stream: MWStream; |
||||
try { |
||||
stream = await runEmbedScraper(scraper, { |
||||
url: embed.url, |
||||
progress(num) { |
||||
ctx.onProgress?.({ |
||||
errored: false, |
||||
eventId, |
||||
id: scraper.id, |
||||
percentage: num, |
||||
type: "embed", |
||||
}); |
||||
}, |
||||
}); |
||||
} catch { |
||||
ctx.onProgress?.({ |
||||
errored: true, |
||||
eventId, |
||||
id: scraper.id, |
||||
percentage: 100, |
||||
type: "embed", |
||||
}); |
||||
continue; |
||||
} |
||||
|
||||
ctx.onProgress?.({ |
||||
errored: false, |
||||
eventId, |
||||
id: scraper.id, |
||||
percentage: 100, |
||||
type: "embed", |
||||
}); |
||||
|
||||
return stream; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
export async function findBestStream( |
||||
ctx: MWProviderRunContext |
||||
): Promise<MWStream | null> { |
||||
const providers = getProviders(); |
||||
|
||||
for (const provider of providers) { |
||||
const eventId = provider.id; |
||||
ctx.onNext?.({ id: provider.id, type: "provider", eventId }); |
||||
let result: MWProviderScrapeResult; |
||||
try { |
||||
let context: MWProviderContext; |
||||
if (ctx.type === MWMediaType.SERIES) { |
||||
context = { |
||||
media: ctx.media, |
||||
type: ctx.type, |
||||
episode: ctx.episode, |
||||
season: ctx.season, |
||||
progress(num) { |
||||
ctx.onProgress?.({ |
||||
percentage: num, |
||||
eventId, |
||||
errored: false, |
||||
id: provider.id, |
||||
type: "provider", |
||||
}); |
||||
}, |
||||
}; |
||||
} else { |
||||
context = { |
||||
media: ctx.media, |
||||
type: ctx.type, |
||||
progress(num) { |
||||
ctx.onProgress?.({ |
||||
percentage: num, |
||||
eventId, |
||||
errored: false, |
||||
id: provider.id, |
||||
type: "provider", |
||||
}); |
||||
}, |
||||
}; |
||||
} |
||||
result = await runProvider(provider, context); |
||||
} catch (err) { |
||||
ctx.onProgress?.({ |
||||
percentage: 100, |
||||
errored: true, |
||||
eventId, |
||||
id: provider.id, |
||||
type: "provider", |
||||
}); |
||||
continue; |
||||
} |
||||
|
||||
ctx.onProgress?.({ |
||||
errored: false, |
||||
id: provider.id, |
||||
eventId, |
||||
percentage: 100, |
||||
type: "provider", |
||||
}); |
||||
|
||||
const stream = await findBestEmbedStream(result, provider.id, ctx); |
||||
if (!stream) continue; |
||||
return stream; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
export enum MWStreamType { |
||||
MP4 = "mp4", |
||||
HLS = "hls", |
||||
} |
||||
|
||||
export enum MWCaptionType { |
||||
VTT = "vtt", |
||||
SRT = "srt", |
||||
} |
||||
|
||||
export enum MWStreamQuality { |
||||
Q360P = "360p", |
||||
Q480P = "480p", |
||||
Q720P = "720p", |
||||
Q1080P = "1080p", |
||||
QUNKNOWN = "unknown", |
||||
} |
||||
|
||||
export type MWCaption = { |
||||
needsProxy?: boolean; |
||||
url: string; |
||||
type: MWCaptionType; |
||||
langIso: string; |
||||
}; |
||||
|
||||
export type MWStream = { |
||||
streamUrl: string; |
||||
type: MWStreamType; |
||||
quality: MWStreamQuality; |
||||
captions: MWCaption[]; |
||||
}; |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
import { initializeScraperStore } from "./helpers/register"; |
||||
|
||||
// providers
|
||||
import "./providers/gdriveplayer"; |
||||
import "./providers/flixhq"; |
||||
import "./providers/superstream"; |
||||
import "./providers/netfilm"; |
||||
import "./providers/m4ufree"; |
||||
|
||||
// embeds
|
||||
import "./embeds/streamm4u"; |
||||
import "./embeds/playm4u"; |
||||
|
||||
initializeScraperStore(); |
||||
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
import { FetchError } from "ofetch"; |
||||
import { makeUrl, proxiedFetch } from "../helpers/fetch"; |
||||
import { |
||||
formatJWMeta, |
||||
JWMediaResult, |
||||
JWSeasonMetaResult, |
||||
JW_API_BASE, |
||||
mediaTypeToJW, |
||||
} from "./justwatch"; |
||||
import { MWMediaMeta, MWMediaType } from "./types"; |
||||
|
||||
type JWExternalIdType = |
||||
| "eidr" |
||||
| "imdb_latest" |
||||
| "imdb" |
||||
| "tmdb_latest" |
||||
| "tmdb" |
||||
| "tms"; |
||||
|
||||
interface JWExternalId { |
||||
provider: JWExternalIdType; |
||||
external_id: string; |
||||
} |
||||
|
||||
interface JWDetailedMeta extends JWMediaResult { |
||||
external_ids: JWExternalId[]; |
||||
} |
||||
|
||||
export interface DetailedMeta { |
||||
meta: MWMediaMeta; |
||||
tmdbId: string; |
||||
imdbId: string; |
||||
} |
||||
|
||||
export async function getMetaFromId( |
||||
type: MWMediaType, |
||||
id: string, |
||||
seasonId?: string |
||||
): Promise<DetailedMeta | null> { |
||||
const queryType = mediaTypeToJW(type); |
||||
|
||||
let data: JWDetailedMeta; |
||||
try { |
||||
const url = makeUrl("/content/titles/{type}/{id}/locale/en_US", { |
||||
type: queryType, |
||||
id, |
||||
}); |
||||
data = await proxiedFetch<JWDetailedMeta>(url, { baseURL: JW_API_BASE }); |
||||
} catch (err) { |
||||
if (err instanceof FetchError) { |
||||
// 400 and 404 are treated as not found
|
||||
if (err.statusCode === 400 || err.statusCode === 404) return null; |
||||
} |
||||
throw err; |
||||
} |
||||
|
||||
const imdbId = data.external_ids.find( |
||||
(v) => v.provider === "imdb_latest" |
||||
)?.external_id; |
||||
const tmdbId = data.external_ids.find( |
||||
(v) => v.provider === "tmdb_latest" |
||||
)?.external_id; |
||||
|
||||
if (!imdbId || !tmdbId) throw new Error("not enough info"); |
||||
|
||||
let seasonData: JWSeasonMetaResult | undefined; |
||||
if (data.object_type === "show") { |
||||
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? ""; |
||||
const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", { |
||||
id: seasonToScrape, |
||||
}); |
||||
seasonData = await proxiedFetch<any>(url, { baseURL: JW_API_BASE }); |
||||
} |
||||
|
||||
return { |
||||
meta: formatJWMeta(data, seasonData), |
||||
imdbId, |
||||
tmdbId, |
||||
}; |
||||
} |
||||
@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
|
||||
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types"; |
||||
|
||||
export const JW_API_BASE = "https://apis.justwatch.com"; |
||||
export const JW_IMAGE_BASE = "https://images.justwatch.com"; |
||||
|
||||
export type JWContentTypes = "movie" | "show"; |
||||
|
||||
export type JWSeasonShort = { |
||||
title: string; |
||||
id: number; |
||||
season_number: number; |
||||
}; |
||||
|
||||
export type JWEpisodeShort = { |
||||
title: string; |
||||
id: number; |
||||
episode_number: number; |
||||
}; |
||||
|
||||
export type JWMediaResult = { |
||||
title: string; |
||||
poster?: string; |
||||
id: number; |
||||
original_release_year: number; |
||||
jw_entity_id: string; |
||||
object_type: JWContentTypes; |
||||
seasons?: JWSeasonShort[]; |
||||
}; |
||||
|
||||
export type JWSeasonMetaResult = { |
||||
title: string; |
||||
id: string; |
||||
season_number: number; |
||||
episodes: JWEpisodeShort[]; |
||||
}; |
||||
|
||||
export function mediaTypeToJW(type: MWMediaType): JWContentTypes { |
||||
if (type === MWMediaType.MOVIE) return "movie"; |
||||
if (type === MWMediaType.SERIES) return "show"; |
||||
throw new Error("unsupported type"); |
||||
} |
||||
|
||||
export function JWMediaToMediaType(type: string): MWMediaType { |
||||
if (type === "movie") return MWMediaType.MOVIE; |
||||
if (type === "show") return MWMediaType.SERIES; |
||||
throw new Error("unsupported type"); |
||||
} |
||||
|
||||
export function formatJWMeta( |
||||
media: JWMediaResult, |
||||
season?: JWSeasonMetaResult |
||||
): MWMediaMeta { |
||||
const type = JWMediaToMediaType(media.object_type); |
||||
let seasons: undefined | MWSeasonMeta[]; |
||||
if (type === MWMediaType.SERIES) { |
||||
seasons = media.seasons |
||||
?.sort((a, b) => a.season_number - b.season_number) |
||||
.map( |
||||
(v): MWSeasonMeta => ({ |
||||
id: v.id.toString(), |
||||
number: v.season_number, |
||||
title: v.title, |
||||
}) |
||||
); |
||||
} |
||||
|
||||
return { |
||||
title: media.title, |
||||
id: media.id.toString(), |
||||
year: media.original_release_year.toString(), |
||||
poster: media.poster |
||||
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}` |
||||
: undefined, |
||||
type, |
||||
seasons: seasons as any, |
||||
seasonData: season |
||||
? ({ |
||||
id: season.id.toString(), |
||||
number: season.season_number, |
||||
title: season.title, |
||||
episodes: season.episodes |
||||
.sort((a, b) => a.episode_number - b.episode_number) |
||||
.map((v) => ({ |
||||
id: v.id.toString(), |
||||
number: v.episode_number, |
||||
title: v.title, |
||||
})), |
||||
} as any) |
||||
: (undefined as any), |
||||
}; |
||||
} |
||||
|
||||
export function JWMediaToId(media: MWMediaMeta): string { |
||||
return ["JW", mediaTypeToJW(media.type), media.id].join("-"); |
||||
} |
||||
|
||||
export function decodeJWId( |
||||
paramId: string |
||||
): { id: string; type: MWMediaType } | null { |
||||
const [prefix, type, id] = paramId.split("-", 3); |
||||
if (prefix !== "JW") return null; |
||||
let mediaType; |
||||
try { |
||||
mediaType = JWMediaToMediaType(type); |
||||
} catch { |
||||
return null; |
||||
} |
||||
return { |
||||
type: mediaType, |
||||
id, |
||||
}; |
||||
} |
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
import { SimpleCache } from "@/utils/cache"; |
||||
import { proxiedFetch } from "../helpers/fetch"; |
||||
import { |
||||
formatJWMeta, |
||||
JWContentTypes, |
||||
JWMediaResult, |
||||
JW_API_BASE, |
||||
mediaTypeToJW, |
||||
} from "./justwatch"; |
||||
import { MWMediaMeta, MWQuery } from "./types"; |
||||
|
||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>(); |
||||
cache.setCompare((a, b) => { |
||||
return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim(); |
||||
}); |
||||
cache.initialize(); |
||||
|
||||
type JWSearchQuery = { |
||||
content_types: JWContentTypes[]; |
||||
page: number; |
||||
page_size: number; |
||||
query: string; |
||||
}; |
||||
|
||||
type JWPage<T> = { |
||||
items: T[]; |
||||
page: number; |
||||
page_size: number; |
||||
total_pages: number; |
||||
total_results: number; |
||||
}; |
||||
|
||||
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> { |
||||
if (cache.has(query)) return cache.get(query) as MWMediaMeta[]; |
||||
const { searchQuery, type } = query; |
||||
|
||||
const contentType = mediaTypeToJW(type); |
||||
const body: JWSearchQuery = { |
||||
content_types: [contentType], |
||||
page: 1, |
||||
query: searchQuery, |
||||
page_size: 40, |
||||
}; |
||||
|
||||
const data = await proxiedFetch<JWPage<JWMediaResult>>( |
||||
"/content/titles/en_US/popular", |
||||
{ |
||||
baseURL: JW_API_BASE, |
||||
params: { |
||||
body: JSON.stringify(body), |
||||
}, |
||||
} |
||||
); |
||||
|
||||
const returnData = data.items.map<MWMediaMeta>((v) => formatJWMeta(v)); |
||||
cache.set(query, returnData, 3600); // cache for an hour
|
||||
return returnData; |
||||
} |
||||
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
export enum MWMediaType { |
||||
MOVIE = "movie", |
||||
SERIES = "series", |
||||
ANIME = "anime", |
||||
} |
||||
|
||||
export type MWSeasonMeta = { |
||||
id: string; |
||||
number: number; |
||||
title: string; |
||||
}; |
||||
|
||||
export type MWSeasonWithEpisodeMeta = { |
||||
id: string; |
||||
number: number; |
||||
title: string; |
||||
episodes: { |
||||
id: string; |
||||
number: number; |
||||
title: string; |
||||
}[]; |
||||
}; |
||||
|
||||
type MWMediaMetaBase = { |
||||
title: string; |
||||
id: string; |
||||
year: string; |
||||
poster?: string; |
||||
}; |
||||
|
||||
type MWMediaMetaSpecific = |
||||
| { |
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME; |
||||
seasons: undefined; |
||||
} |
||||
| { |
||||
type: MWMediaType.SERIES; |
||||
seasons: MWSeasonMeta[]; |
||||
seasonData: MWSeasonWithEpisodeMeta; |
||||
}; |
||||
|
||||
export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific; |
||||
|
||||
export interface MWQuery { |
||||
searchQuery: string; |
||||
type: MWMediaType; |
||||
} |
||||
@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
|
||||
import { compareTitle } from "@/utils/titleMatch"; |
||||
import { proxiedFetch } from "../helpers/fetch"; |
||||
import { registerProvider } from "../helpers/register"; |
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams"; |
||||
import { MWMediaType } from "../metadata/types"; |
||||
|
||||
const flixHqBase = "https://api.consumet.org/movies/flixhq"; |
||||
|
||||
registerProvider({ |
||||
id: "flixhq", |
||||
displayName: "FlixHQ", |
||||
rank: 100, |
||||
type: [MWMediaType.MOVIE], |
||||
|
||||
async scrape({ media, progress }) { |
||||
// search for relevant item
|
||||
const searchResults = await proxiedFetch<any>( |
||||
`/${encodeURIComponent(media.meta.title)}`, |
||||
{ |
||||
baseURL: flixHqBase, |
||||
} |
||||
); |
||||
const foundItem = searchResults.results.find((v: any) => { |
||||
return ( |
||||
compareTitle(v.title, media.meta.title) && |
||||
v.releaseDate === media.meta.year |
||||
); |
||||
}); |
||||
if (!foundItem) throw new Error("No watchable item found"); |
||||
const flixId = foundItem.id; |
||||
|
||||
// get media info
|
||||
progress(25); |
||||
const mediaInfo = await proxiedFetch<any>("/info", { |
||||
baseURL: flixHqBase, |
||||
params: { |
||||
id: flixId, |
||||
}, |
||||
}); |
||||
|
||||
// get stream info from media
|
||||
progress(75); |
||||
const watchInfo = await proxiedFetch<any>("/watch", { |
||||
baseURL: flixHqBase, |
||||
params: { |
||||
episodeId: mediaInfo.episodes[0].id, |
||||
mediaId: flixId, |
||||
}, |
||||
}); |
||||
|
||||
// get best quality source
|
||||
const source = watchInfo.sources.reduce((p: any, c: any) => |
||||
c.quality > p.quality ? c : p |
||||
); |
||||
|
||||
return { |
||||
embeds: [], |
||||
stream: { |
||||
streamUrl: source.url, |
||||
quality: MWStreamQuality.QUNKNOWN, |
||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4, |
||||
captions: [], |
||||
}, |
||||
}; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,235 @@
@@ -0,0 +1,235 @@
|
||||
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed"; |
||||
import { proxiedFetch } from "../helpers/fetch"; |
||||
import { registerProvider } from "../helpers/register"; |
||||
import { MWMediaType } from "../metadata/types"; |
||||
|
||||
const HOST = "m4ufree.com"; |
||||
const URL_BASE = `https://${HOST}`; |
||||
const URL_SEARCH = `${URL_BASE}/search`; |
||||
const URL_AJAX = `${URL_BASE}/ajax`; |
||||
const URL_AJAX_TV = `${URL_BASE}/ajaxtv`; |
||||
|
||||
// * Years can be in one of 4 formats:
|
||||
// * - "startyear" (for movies, EX: 2022)
|
||||
// * - "startyear-" (for TV series which has not ended, EX: 2022-)
|
||||
// * - "startyear-endyear" (for TV series which has ended, EX: 2022-2023)
|
||||
// * - "startyearendyear" (for TV series which has ended, EX: 20222023)
|
||||
const REGEX_TITLE_AND_YEAR = /(.*) \(?(\d*|\d*-|\d*-\d*)\)?$/; |
||||
const REGEX_TYPE = /.*-(movie|tvshow)-online-free-m4ufree\.html/; |
||||
const REGEX_COOKIES = /XSRF-TOKEN=(.*?);.*laravel_session=(.*?);/; |
||||
const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/; |
||||
|
||||
function toDom(html: string) { |
||||
return new DOMParser().parseFromString(html, "text/html"); |
||||
} |
||||
|
||||
registerProvider({ |
||||
id: "m4ufree", |
||||
displayName: "m4ufree", |
||||
rank: -1, |
||||
disabled: true, // Disables because the redirector URLs it returns will throw 404 / 403 depending on if you view it in the browser or fetch it respectively. It just does not work.
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
|
||||
async scrape({ media, type, episode: episodeId, season: seasonId }) { |
||||
const season = |
||||
media.meta.seasons?.find((s) => s.id === seasonId)?.number || 1; |
||||
const episode = |
||||
media.meta.type === MWMediaType.SERIES |
||||
? media.meta.seasonData.episodes.find((ep) => ep.id === episodeId) |
||||
?.number || 1 |
||||
: undefined; |
||||
|
||||
const embeds: MWEmbed[] = []; |
||||
|
||||
/* |
||||
, { |
||||
responseType: "text" as any, |
||||
} |
||||
*/ |
||||
const responseText = await proxiedFetch<string>( |
||||
`${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html` |
||||
); |
||||
let dom = toDom(responseText); |
||||
|
||||
const searchResults = [...dom.querySelectorAll(".item")] |
||||
.map((element) => { |
||||
const tooltipText = element.querySelector(".tiptitle p")?.innerHTML; |
||||
if (!tooltipText) return; |
||||
|
||||
let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText); |
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) { |
||||
return; |
||||
} |
||||
|
||||
const title = regexResult[1]; |
||||
const year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
|
||||
const a = element.querySelector("a"); |
||||
if (!a) return; |
||||
const href = a.href; |
||||
|
||||
regexResult = REGEX_TYPE.exec(href); |
||||
|
||||
if (!regexResult || !regexResult[1]) { |
||||
return; |
||||
} |
||||
|
||||
let scraperDeterminedType = regexResult[1]; |
||||
|
||||
scraperDeterminedType = |
||||
scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type
|
||||
|
||||
return { type: scraperDeterminedType, title, year, href }; |
||||
}) |
||||
.filter((item) => item); |
||||
|
||||
const mediaInResults = searchResults.find( |
||||
(item) => |
||||
item && |
||||
item.title === media.meta.title && |
||||
item.year.toString() === media.meta.year |
||||
); |
||||
|
||||
if (!mediaInResults) { |
||||
// * Nothing found
|
||||
return { |
||||
embeds, |
||||
}; |
||||
} |
||||
|
||||
let cookies: string | null = ""; |
||||
const responseTextFromMedia = await proxiedFetch<string>( |
||||
mediaInResults.href, |
||||
{ |
||||
onResponse(context) { |
||||
cookies = context.response.headers.get("X-Set-Cookie"); |
||||
}, |
||||
} |
||||
); |
||||
dom = toDom(responseTextFromMedia); |
||||
|
||||
let regexResult = REGEX_COOKIES.exec(cookies); |
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) { |
||||
// * DO SOMETHING?
|
||||
throw new Error("No regexResults, yikesssssss kinda gross idk"); |
||||
} |
||||
|
||||
const cookieHeader = `XSRF-TOKEN=${regexResult[1]}; laravel_session=${regexResult[2]}`; |
||||
|
||||
const token = dom |
||||
.querySelector('meta[name="csrf-token"]') |
||||
?.getAttribute("content"); |
||||
if (!token) return { embeds }; |
||||
|
||||
if (type === MWMediaType.SERIES) { |
||||
// * Get the season/episode data
|
||||
const episodes = [...dom.querySelectorAll(".episode")] |
||||
.map((element) => { |
||||
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML); |
||||
|
||||
if (!regexResult || !regexResult[1] || !regexResult[2]) { |
||||
return; |
||||
} |
||||
|
||||
const newEpisode = Number(regexResult[1]); |
||||
const newSeason = Number(regexResult[2]); |
||||
|
||||
return { |
||||
id: element.getAttribute("idepisode"), |
||||
episode: newEpisode, |
||||
season: newSeason, |
||||
}; |
||||
}) |
||||
.filter((item) => item); |
||||
|
||||
const ep = episodes.find( |
||||
(newEp) => newEp && newEp.episode === episode && newEp.season === season |
||||
); |
||||
if (!ep) return { embeds }; |
||||
|
||||
const form = `idepisode=${ep.id}&_token=${token}`; |
||||
|
||||
const response = await proxiedFetch<string>(URL_AJAX_TV, { |
||||
method: "POST", |
||||
headers: { |
||||
Accept: "*/*", |
||||
"Accept-Encoding": "gzip, deflate, br", |
||||
"Accept-Language": "en-US,en;q=0.9", |
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", |
||||
"X-Requested-With": "XMLHttpRequest", |
||||
"Sec-CH-UA": |
||||
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"', |
||||
"Sec-CH-UA-Mobile": "?0", |
||||
"Sec-CH-UA-Platform": '"Linux"', |
||||
"Sec-Fetch-Site": "same-origin", |
||||
"Sec-Fetch-Mode": "cors", |
||||
"Sec-Fetch-Dest": "empty", |
||||
"X-Cookie": cookieHeader, |
||||
"X-Origin": URL_BASE, |
||||
"X-Referer": mediaInResults.href, |
||||
}, |
||||
body: form, |
||||
}); |
||||
|
||||
dom = toDom(response); |
||||
} |
||||
|
||||
const servers = [...dom.querySelectorAll(".singlemv")].map((element) => |
||||
element.getAttribute("data") |
||||
); |
||||
|
||||
for (const server of servers) { |
||||
const form = `m4u=${server}&_token=${token}`; |
||||
|
||||
const response = await proxiedFetch<string>(URL_AJAX, { |
||||
method: "POST", |
||||
headers: { |
||||
Accept: "*/*", |
||||
"Accept-Encoding": "gzip, deflate, br", |
||||
"Accept-Language": "en-US,en;q=0.9", |
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", |
||||
"X-Requested-With": "XMLHttpRequest", |
||||
"Sec-CH-UA": |
||||
'"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"', |
||||
"Sec-CH-UA-Mobile": "?0", |
||||
"Sec-CH-UA-Platform": '"Linux"', |
||||
"Sec-Fetch-Site": "same-origin", |
||||
"Sec-Fetch-Mode": "cors", |
||||
"Sec-Fetch-Dest": "empty", |
||||
"X-Cookie": cookieHeader, |
||||
"X-Origin": URL_BASE, |
||||
"X-Referer": mediaInResults.href, |
||||
}, |
||||
body: form, |
||||
}); |
||||
|
||||
const serverDom = toDom(response); |
||||
|
||||
const link = serverDom.querySelector("iframe")?.src; |
||||
|
||||
const getEmbedType = (url: string) => { |
||||
if (url.startsWith("https://streamm4u.club")) |
||||
return MWEmbedType.STREAMM4U; |
||||
if (url.startsWith("https://play.playm4u.xyz")) |
||||
return MWEmbedType.PLAYM4U; |
||||
return null; |
||||
}; |
||||
|
||||
if (!link) continue; |
||||
|
||||
const embedType = getEmbedType(link); |
||||
if (embedType) { |
||||
embeds.push({ |
||||
url: link, |
||||
type: embedType, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
console.log(embeds); |
||||
return { |
||||
embeds, |
||||
}; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
|
||||
import { proxiedFetch } from "../helpers/fetch"; |
||||
import { registerProvider } from "../helpers/register"; |
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams"; |
||||
import { MWMediaType } from "../metadata/types"; |
||||
|
||||
const netfilmBase = "https://net-film.vercel.app"; |
||||
|
||||
const qualityMap = { |
||||
"360": MWStreamQuality.Q360P, |
||||
"480": MWStreamQuality.Q480P, |
||||
"720": MWStreamQuality.Q720P, |
||||
"1080": MWStreamQuality.Q1080P, |
||||
}; |
||||
type QualityInMap = keyof typeof qualityMap; |
||||
|
||||
registerProvider({ |
||||
id: "netfilm", |
||||
displayName: "NetFilm", |
||||
rank: 150, |
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
|
||||
async scrape({ media, episode, progress }) { |
||||
// search for relevant item
|
||||
const searchResponse = await proxiedFetch<any>( |
||||
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`, |
||||
{ |
||||
baseURL: netfilmBase, |
||||
} |
||||
); |
||||
|
||||
const searchResults = searchResponse.data.results; |
||||
progress(25); |
||||
|
||||
if (media.meta.type === MWMediaType.MOVIE) { |
||||
const foundItem = searchResults.find((v: any) => { |
||||
return v.name === media.meta.title && v.releaseTime === media.meta.year; |
||||
}); |
||||
if (!foundItem) throw new Error("No watchable item found"); |
||||
const netfilmId = foundItem.id; |
||||
|
||||
// get stream info from media
|
||||
progress(75); |
||||
const watchInfo = await proxiedFetch<any>( |
||||
`/api/episode?id=${netfilmId}`, |
||||
{ |
||||
baseURL: netfilmBase, |
||||
} |
||||
); |
||||
|
||||
const { qualities } = watchInfo.data; |
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) => |
||||
c.quality > p.quality ? c : p |
||||
); |
||||
|
||||
return { |
||||
embeds: [], |
||||
stream: { |
||||
streamUrl: source.url, |
||||
quality: qualityMap[source.quality as QualityInMap], |
||||
type: MWStreamType.HLS, |
||||
captions: [], |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
if (media.meta.type !== MWMediaType.SERIES) |
||||
throw new Error("Unsupported type"); |
||||
|
||||
const desiredSeason = media.meta.seasonData.number; |
||||
|
||||
const searchItems = searchResults |
||||
.filter((v: any) => { |
||||
return v.name.includes(media.meta.title); |
||||
}) |
||||
.map((v: any) => { |
||||
return { |
||||
...v, |
||||
season: parseInt(v.name.split(" ").at(-1), 10) || 1, |
||||
}; |
||||
}); |
||||
|
||||
const foundItem = searchItems.find((v: any) => { |
||||
return v.season === desiredSeason; |
||||
}); |
||||
|
||||
progress(50); |
||||
const seasonDetail = await proxiedFetch<any>( |
||||
`/api/detail?id=${foundItem.id}&category=${foundItem.categoryTag[0].id}`, |
||||
{ |
||||
baseURL: netfilmBase, |
||||
} |
||||
); |
||||
|
||||
const episodeNo = media.meta.seasonData.episodes.find( |
||||
(v: any) => v.id === episode |
||||
)?.number; |
||||
const episodeData = seasonDetail.data.episodeVo.find( |
||||
(v: any) => v.seriesNo === episodeNo |
||||
); |
||||
|
||||
progress(75); |
||||
const episodeStream = await proxiedFetch<any>( |
||||
`/api/episode?id=${foundItem.id}&category=1&episode=${episodeData.id}`, |
||||
{ |
||||
baseURL: netfilmBase, |
||||
} |
||||
); |
||||
|
||||
const { qualities } = episodeStream.data; |
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) => |
||||
c.quality > p.quality ? c : p |
||||
); |
||||
|
||||
return { |
||||
embeds: [], |
||||
stream: { |
||||
streamUrl: source.url, |
||||
quality: qualityMap[source.quality as QualityInMap], |
||||
type: MWStreamType.HLS, |
||||
captions: [], |
||||
}, |
||||
}; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
|
||||
import { registerProvider } from "@/backend/helpers/register"; |
||||
import { MWMediaType } from "@/backend/metadata/types"; |
||||
|
||||
import { customAlphabet } from "nanoid"; |
||||
import CryptoJS from "crypto-js"; |
||||
import { proxiedFetch } from "@/backend/helpers/fetch"; |
||||
import { |
||||
MWCaption, |
||||
MWCaptionType, |
||||
MWStreamQuality, |
||||
MWStreamType, |
||||
} from "@/backend/helpers/streams"; |
||||
import { compareTitle } from "@/utils/titleMatch"; |
||||
|
||||
const nanoid = customAlphabet("0123456789abcdef", 32); |
||||
|
||||
const qualityMap = { |
||||
"360p": MWStreamQuality.Q360P, |
||||
"480p": MWStreamQuality.Q480P, |
||||
"720p": MWStreamQuality.Q720P, |
||||
"1080p": MWStreamQuality.Q1080P, |
||||
}; |
||||
type QualityInMap = keyof typeof qualityMap; |
||||
|
||||
// CONSTANTS, read below (taken from og)
|
||||
// We do not want content scanners to notice this scraping going on so we've hidden all constants
|
||||
// The source has its origins in China so I added some extra security with banned words
|
||||
// Mayhaps a tiny bit unethical, but this source is just too good :)
|
||||
// If you are copying this code please use precautions so they do not change their api.
|
||||
const iv = atob("d0VpcGhUbiE="); |
||||
const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2"); |
||||
const apiUrls = [ |
||||
atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="), |
||||
atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="), |
||||
]; |
||||
const appKey = atob("bW92aWVib3g="); |
||||
const appId = atob("Y29tLnRkby5zaG93Ym94"); |
||||
|
||||
// cryptography stuff
|
||||
const crypto = { |
||||
encrypt(str: string) { |
||||
return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), { |
||||
iv: CryptoJS.enc.Utf8.parse(iv), |
||||
}).toString(); |
||||
}, |
||||
getVerify(str: string, str2: string, str3: string) { |
||||
if (str) { |
||||
return CryptoJS.MD5( |
||||
CryptoJS.MD5(str2).toString() + str3 + str |
||||
).toString(); |
||||
} |
||||
return null; |
||||
}, |
||||
}; |
||||
|
||||
// get expire time
|
||||
const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12); |
||||
|
||||
// sending requests
|
||||
const get = (data: object, altApi = false) => { |
||||
const defaultData = { |
||||
childmode: "0", |
||||
app_version: "11.5", |
||||
appid: appId, |
||||
lang: "en", |
||||
expired_date: `${expiry()}`, |
||||
platform: "android", |
||||
channel: "Website", |
||||
}; |
||||
const encryptedData = crypto.encrypt( |
||||
JSON.stringify({ |
||||
...defaultData, |
||||
...data, |
||||
}) |
||||
); |
||||
const appKeyHash = CryptoJS.MD5(appKey).toString(); |
||||
const verify = crypto.getVerify(encryptedData, appKey, key); |
||||
const body = JSON.stringify({ |
||||
app_key: appKeyHash, |
||||
verify, |
||||
encrypt_data: encryptedData, |
||||
}); |
||||
const b64Body = btoa(body); |
||||
|
||||
const formatted = new URLSearchParams(); |
||||
formatted.append("data", b64Body); |
||||
formatted.append("appid", "27"); |
||||
formatted.append("platform", "android"); |
||||
formatted.append("version", "129"); |
||||
formatted.append("medium", "Website"); |
||||
|
||||
const requestUrl = altApi ? apiUrls[1] : apiUrls[0]; |
||||
return proxiedFetch<any>(requestUrl, { |
||||
method: "POST", |
||||
parseResponse: JSON.parse, |
||||
headers: { |
||||
Platform: "android", |
||||
"Content-Type": "application/x-www-form-urlencoded", |
||||
}, |
||||
body: `${formatted.toString()}&token${nanoid()}`, |
||||
}); |
||||
}; |
||||
|
||||
// Find best resolution
|
||||
const getBestQuality = (list: any[]) => { |
||||
return ( |
||||
list.find((quality: any) => quality.quality === "1080p" && quality.path) ?? |
||||
list.find((quality: any) => quality.quality === "720p" && quality.path) ?? |
||||
list.find((quality: any) => quality.quality === "480p" && quality.path) ?? |
||||
list.find((quality: any) => quality.quality === "360p" && quality.path) |
||||
); |
||||
}; |
||||
|
||||
registerProvider({ |
||||
id: "superstream", |
||||
displayName: "Superstream", |
||||
rank: 200, |
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
|
||||
async scrape({ media, episode, progress }) { |
||||
// Find Superstream ID for show
|
||||
const searchQuery = { |
||||
module: "Search3", |
||||
page: "1", |
||||
type: "all", |
||||
keyword: media.meta.title, |
||||
pagelimit: "20", |
||||
}; |
||||
const searchRes = (await get(searchQuery, true)).data; |
||||
progress(33); |
||||
|
||||
const superstreamEntry = searchRes.find( |
||||
(res: any) => |
||||
compareTitle(res.title, media.meta.title) && |
||||
res.year === Number(media.meta.year) |
||||
); |
||||
|
||||
if (!superstreamEntry) throw new Error("No entry found on SuperStream"); |
||||
const superstreamId = superstreamEntry.id; |
||||
|
||||
// Movie logic
|
||||
if (media.meta.type === MWMediaType.MOVIE) { |
||||
const apiQuery = { |
||||
uid: "", |
||||
module: "Movie_downloadurl_v3", |
||||
mid: superstreamId, |
||||
oss: "1", |
||||
group: "", |
||||
}; |
||||
|
||||
const mediaRes = (await get(apiQuery)).data; |
||||
progress(50); |
||||
|
||||
const hdQuality = getBestQuality(mediaRes.list); |
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found."); |
||||
|
||||
const subtitleApiQuery = { |
||||
fid: hdQuality.fid, |
||||
uid: "", |
||||
module: "Movie_srt_list_v2", |
||||
mid: superstreamId, |
||||
}; |
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data; |
||||
|
||||
const mappedCaptions = subtitleRes.list.map( |
||||
(subtitle: any): MWCaption => { |
||||
return { |
||||
needsProxy: true, |
||||
langIso: subtitle.language, |
||||
url: subtitle.subtitles[0].file_path, |
||||
type: MWCaptionType.SRT, |
||||
}; |
||||
} |
||||
); |
||||
|
||||
return { |
||||
embeds: [], |
||||
stream: { |
||||
streamUrl: hdQuality.path, |
||||
quality: qualityMap[hdQuality.quality as QualityInMap], |
||||
type: MWStreamType.MP4, |
||||
captions: mappedCaptions, |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
if (media.meta.type !== MWMediaType.SERIES) |
||||
throw new Error("Unsupported type"); |
||||
|
||||
// Fetch requested episode
|
||||
const apiQuery = { |
||||
uid: "", |
||||
module: "TV_downloadurl_v3", |
||||
tid: superstreamId, |
||||
season: media.meta.seasonData.number.toString(), |
||||
episode: ( |
||||
media.meta.seasonData.episodes.find( |
||||
(episodeInfo) => episodeInfo.id === episode |
||||
)?.number ?? 1 |
||||
).toString(), |
||||
oss: "1", |
||||
group: "", |
||||
}; |
||||
|
||||
const mediaRes = (await get(apiQuery)).data; |
||||
progress(66); |
||||
|
||||
const hdQuality = getBestQuality(mediaRes.list); |
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found."); |
||||
|
||||
const subtitleApiQuery = { |
||||
fid: hdQuality.fid, |
||||
uid: "", |
||||
module: "TV_srt_list_v2", |
||||
episode: |
||||
media.meta.seasonData.episodes.find( |
||||
(episodeInfo) => episodeInfo.id === episode |
||||
)?.number ?? 1, |
||||
tid: superstreamId, |
||||
season: media.meta.seasonData.number.toString(), |
||||
}; |
||||
|
||||
const subtitleRes = (await get(subtitleApiQuery)).data; |
||||
|
||||
const mappedCaptions = subtitleRes.list.map((subtitle: any): MWCaption => { |
||||
return { |
||||
needsProxy: true, |
||||
langIso: subtitle.language, |
||||
url: subtitle.subtitles[0].file_path, |
||||
type: MWCaptionType.SRT, |
||||
}; |
||||
}); |
||||
|
||||
return { |
||||
embeds: [], |
||||
stream: { |
||||
quality: qualityMap[ |
||||
hdQuality.quality as QualityInMap |
||||
] as MWStreamQuality, |
||||
streamUrl: hdQuality.path, |
||||
type: MWStreamType.MP4, |
||||
captions: mappedCaptions, |
||||
}, |
||||
}; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { ReactNode } from "react"; |
||||
|
||||
interface Props { |
||||
icon?: Icons; |
||||
onClick?: () => void; |
||||
children?: ReactNode; |
||||
} |
||||
|
||||
export function Button(props: Props) { |
||||
return ( |
||||
<button |
||||
type="button" |
||||
onClick={props.onClick} |
||||
className="inline-flex items-center justify-center rounded-lg bg-white px-8 py-3 font-bold text-black transition-[transform,background-color] duration-100 hover:bg-gray-200 active:scale-105 md:px-16" |
||||
> |
||||
{props.icon ? ( |
||||
<span className="mr-3 hidden md:inline-block"> |
||||
<Icon icon={props.icon} /> |
||||
</span> |
||||
) : null} |
||||
{props.children} |
||||
</button> |
||||
); |
||||
} |
||||
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
import { Transition } from "@/components/Transition"; |
||||
import { Helmet } from "react-helmet"; |
||||
|
||||
export function Overlay(props: { children: React.ReactNode }) { |
||||
return ( |
||||
<> |
||||
<Helmet> |
||||
<body data-no-scroll /> |
||||
</Helmet> |
||||
<div className="fixed inset-0 z-[99999]"> |
||||
<Transition |
||||
animation="fade" |
||||
className="absolute inset-0 bg-[rgba(8,6,18,0.85)]" |
||||
isChild |
||||
/> |
||||
{props.children} |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
|
||||
import { Fragment, ReactNode } from "react"; |
||||
import { |
||||
Transition as HeadlessTransition, |
||||
TransitionClasses, |
||||
} from "@headlessui/react"; |
||||
|
||||
type TransitionAnimations = "slide-down" | "slide-up" | "fade" | "none"; |
||||
|
||||
interface Props { |
||||
show?: boolean; |
||||
durationClass?: string; |
||||
animation: TransitionAnimations; |
||||
className?: string; |
||||
children?: ReactNode; |
||||
isChild?: boolean; |
||||
} |
||||
|
||||
function getClasses( |
||||
animation: TransitionAnimations, |
||||
duration: string |
||||
): TransitionClasses { |
||||
if (animation === "slide-down") { |
||||
return { |
||||
leave: `transition-[transform,opacity] ${duration}`, |
||||
leaveFrom: "opacity-100 translate-y-0", |
||||
leaveTo: "-translate-y-4 opacity-0", |
||||
enter: `transition-[transform,opacity] ${duration}`, |
||||
enterFrom: "opacity-0 -translate-y-4", |
||||
enterTo: "translate-y-0 opacity-100", |
||||
}; |
||||
} |
||||
|
||||
if (animation === "slide-up") { |
||||
return { |
||||
leave: `transition-[transform,opacity] ${duration}`, |
||||
leaveFrom: "opacity-100 translate-y-0", |
||||
leaveTo: "translate-y-4 opacity-0", |
||||
enter: `transition-[transform,opacity] ${duration}`, |
||||
enterFrom: "opacity-0 translate-y-4", |
||||
enterTo: "translate-y-0 opacity-100", |
||||
}; |
||||
} |
||||
|
||||
if (animation === "fade") { |
||||
return { |
||||
leave: `transition-[transform,opacity] ${duration}`, |
||||
leaveFrom: "opacity-100", |
||||
leaveTo: "opacity-0", |
||||
enter: `transition-[transform,opacity] ${duration}`, |
||||
enterFrom: "opacity-0", |
||||
enterTo: "opacity-100", |
||||
}; |
||||
} |
||||
|
||||
return {}; |
||||
} |
||||
|
||||
export function Transition(props: Props) { |
||||
const duration = props.durationClass ?? "duration-200"; |
||||
const classes = getClasses(props.animation, duration); |
||||
|
||||
if (props.isChild) { |
||||
return ( |
||||
<HeadlessTransition.Child as={Fragment} {...classes}> |
||||
<div className={props.className}>{props.children}</div> |
||||
</HeadlessTransition.Child> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<HeadlessTransition show={props.show} as={Fragment} {...classes}> |
||||
<div className={props.className}>{props.children}</div> |
||||
</HeadlessTransition> |
||||
); |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { useAutoAnimate } from "@formkit/auto-animate/react"; |
||||
import { useCallback } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
import { ButtonControl } from "./ButtonControl"; |
||||
|
||||
export interface EditButtonProps { |
||||
editing: boolean; |
||||
onEdit?: (editing: boolean) => void; |
||||
} |
||||
|
||||
export function EditButton(props: EditButtonProps) { |
||||
const { t } = useTranslation(); |
||||
const [parent] = useAutoAnimate<HTMLSpanElement>(); |
||||
|
||||
const onClick = useCallback(() => { |
||||
props.onEdit?.(!props.editing); |
||||
}, [props]); |
||||
|
||||
return ( |
||||
<ButtonControl |
||||
onClick={onClick} |
||||
className="flex h-12 items-center overflow-hidden rounded-full bg-denim-400 px-4 py-2 text-white transition-[background-color,transform] hover:bg-denim-500 active:scale-105" |
||||
> |
||||
<span ref={parent}> |
||||
{props.editing ? ( |
||||
<span className="mx-4 whitespace-nowrap"> |
||||
{t("media.stopEditing")} |
||||
</span> |
||||
) : ( |
||||
<Icon icon={Icons.EDIT} /> |
||||
)} |
||||
</span> |
||||
</ButtonControl> |
||||
); |
||||
} |
||||
@ -1,18 +1,19 @@
@@ -1,18 +1,19 @@
|
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { useTranslation } from "react-i18next"; |
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
|
||||
export function BrandPill(props: { clickable?: boolean }) { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div |
||||
className={`flex items-center space-x-2 rounded-full bg-bink-100 bg-opacity-50 px-4 py-2 text-bink-600 ${props.clickable |
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-200 hover:text-bink-700 active:scale-95" |
||||
className={`flex items-center space-x-2 rounded-full bg-bink-300 bg-opacity-50 px-4 py-2 text-bink-600 ${ |
||||
props.clickable |
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-400 hover:text-bink-700 active:scale-95" |
||||
: "" |
||||
}`}
|
||||
}`}
|
||||
> |
||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} /> |
||||
<span className="font-semibold text-white">{t('global.name')}</span> |
||||
<span className="font-semibold text-white">{t("global.name")}</span> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
import { Overlay } from "@/components/Overlay"; |
||||
import { Transition } from "@/components/Transition"; |
||||
import { ReactNode } from "react"; |
||||
import { createPortal } from "react-dom"; |
||||
|
||||
interface Props { |
||||
show: boolean; |
||||
children?: ReactNode; |
||||
} |
||||
|
||||
export function ModalFrame(props: Props) { |
||||
return ( |
||||
<Transition |
||||
className="fixed inset-0 z-[9999]" |
||||
animation="none" |
||||
show={props.show} |
||||
> |
||||
<Overlay> |
||||
<Transition |
||||
isChild |
||||
className="flex h-full w-full items-center justify-center" |
||||
animation="slide-up" |
||||
> |
||||
{props.children} |
||||
</Transition> |
||||
</Overlay> |
||||
</Transition> |
||||
); |
||||
} |
||||
|
||||
export function Modal(props: Props) { |
||||
return createPortal( |
||||
<ModalFrame show={props.show}>{props.children}</ModalFrame>, |
||||
document.body |
||||
); |
||||
} |
||||
|
||||
export function ModalCard(props: { children?: ReactNode }) { |
||||
return ( |
||||
<div className="relative mx-2 max-w-[600px] overflow-hidden rounded-lg bg-denim-200 px-10 py-10"> |
||||
{props.children} |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,14 +1,16 @@
@@ -1,14 +1,16 @@
|
||||
import { ReactNode } from "react"; |
||||
|
||||
export interface PaperProps { |
||||
children?: ReactNode, |
||||
className?: string, |
||||
children?: ReactNode; |
||||
className?: string; |
||||
} |
||||
|
||||
export function Paper(props: PaperProps) { |
||||
return ( |
||||
<div className={`bg-denim-200 lg:rounded-xl px-4 sm:px-8 md:px-12 py-6 sm:py-8 md:py-12 ${props.className}`}> |
||||
<div |
||||
className={`bg-denim-200 px-4 py-6 sm:px-8 sm:py-8 md:px-12 md:py-12 lg:rounded-xl ${props.className}`} |
||||
> |
||||
{props.children} |
||||
</div> |
||||
) |
||||
); |
||||
} |
||||
|
||||
@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
|
||||
interface Props { |
||||
className?: string; |
||||
radius?: number; |
||||
percentage: number; |
||||
backingRingClassname?: string; |
||||
} |
||||
|
||||
export function ProgressRing(props: Props) { |
||||
const radius = props.radius ?? 40; |
||||
|
||||
return ( |
||||
<svg |
||||
className={`${props.className ?? ""} -rotate-90`} |
||||
viewBox="0 0 100 100" |
||||
> |
||||
<circle |
||||
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${ |
||||
props.backingRingClassname ?? "" |
||||
}`}
|
||||
r={radius} |
||||
cx="50" |
||||
cy="50" |
||||
/> |
||||
<circle |
||||
className="fill-transparent stroke-current stroke-[15] transition-[stroke-dashoffset] duration-150" |
||||
r={radius} |
||||
cx="50" |
||||
cy="50" |
||||
style={{ |
||||
strokeDasharray: `${2 * Math.PI * radius} ${2 * Math.PI * radius}`, |
||||
strokeDashoffset: `${ |
||||
2 * Math.PI * radius - |
||||
(props.percentage / 100) * (2 * Math.PI * radius) |
||||
}`,
|
||||
}} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
@ -1,124 +0,0 @@
@@ -1,124 +0,0 @@
|
||||
import { useEffect, useState } from "react"; |
||||
import { useHistory } from "react-router-dom"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Dropdown, OptionItem } from "@/components/Dropdown"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { WatchedEpisode } from "@/components/media/WatchedEpisodeButton"; |
||||
import { useLoading } from "@/hooks/useLoading"; |
||||
import { serializePortableMedia } from "@/hooks/usePortableMedia"; |
||||
import { |
||||
convertMediaToPortable, |
||||
MWMedia, |
||||
MWMediaSeasons, |
||||
MWMediaSeason, |
||||
MWPortableMedia, |
||||
} from "@/providers"; |
||||
import { getSeasonDataFromMedia } from "@/providers/methods/seasons"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
export interface SeasonsProps { |
||||
media: MWMedia; |
||||
} |
||||
|
||||
export function LoadingSeasons(props: { error?: boolean }) { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div> |
||||
<div> |
||||
<div className="mb-3 mt-5 h-10 w-56 rounded bg-denim-400 opacity-50" /> |
||||
</div> |
||||
{!props.error ? ( |
||||
<> |
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" /> |
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" /> |
||||
<div className="mr-3 mb-3 inline-block h-10 w-10 rounded bg-denim-400 opacity-50" /> |
||||
</> |
||||
) : ( |
||||
<div className="flex items-center space-x-3"> |
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" /> |
||||
<p>{t('seasons.failed')}</p> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function Seasons(props: SeasonsProps) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [searchSeasons, loading, error, success] = useLoading( |
||||
(portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia) |
||||
); |
||||
const history = useHistory(); |
||||
const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] }); |
||||
const seasonSelected = props.media.seasonId as string; |
||||
const episodeSelected = props.media.episodeId as string; |
||||
|
||||
useEffect(() => { |
||||
(async () => { |
||||
const seasonData = await searchSeasons(props.media); |
||||
setSeasons(seasonData); |
||||
})(); |
||||
}, [searchSeasons, props.media]); |
||||
|
||||
function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) { |
||||
const newMedia: MWMedia = { ...props.media }; |
||||
newMedia.episodeId = episodeId; |
||||
newMedia.seasonId = seasonId; |
||||
history.replace( |
||||
`/media/${newMedia.mediaType}/${serializePortableMedia( |
||||
convertMediaToPortable(newMedia) |
||||
)}` |
||||
); |
||||
} |
||||
|
||||
const mapSeason = (season: MWMediaSeason) => ({ |
||||
id: season.id, |
||||
name: season.title || `${t('seasons.season', { season: season.sort })}`, |
||||
}); |
||||
|
||||
const options = seasons.seasons.map(mapSeason); |
||||
|
||||
const foundSeason = seasons.seasons.find( |
||||
(season) => season.id === seasonSelected |
||||
); |
||||
const selectedItem = foundSeason ? mapSeason(foundSeason) : null; |
||||
|
||||
return ( |
||||
<> |
||||
{loading ? <LoadingSeasons /> : null} |
||||
{error ? <LoadingSeasons error /> : null} |
||||
{success && seasons.seasons.length ? ( |
||||
<> |
||||
<Dropdown |
||||
selectedItem={selectedItem as OptionItem} |
||||
options={options} |
||||
setSelectedItem={(seasonItem) => |
||||
navigateToSeasonAndEpisode( |
||||
seasonItem.id, |
||||
seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0] |
||||
.id as string |
||||
) |
||||
} |
||||
/> |
||||
{seasons.seasons |
||||
.find((s) => s.id === seasonSelected) |
||||
?.episodes.map((v) => ( |
||||
<WatchedEpisode |
||||
key={v.id} |
||||
media={{ |
||||
...props.media, |
||||
seriesData: seasons, |
||||
episodeId: v.id, |
||||
seasonId: seasonSelected, |
||||
}} |
||||
active={v.id === episodeSelected} |
||||
onClick={() => navigateToSeasonAndEpisode(seasonSelected, v.id)} |
||||
/> |
||||
))} |
||||
</> |
||||
) : null} |
||||
</> |
||||
); |
||||
} |
||||
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
.spinner { |
||||
font-size: 48px; |
||||
width: 1em; |
||||
height: 1em; |
||||
border: 0.12em solid var(--color,white); |
||||
border-bottom-color: transparent; |
||||
border-radius: 50%; |
||||
display: inline-block; |
||||
box-sizing: border-box; |
||||
animation: spinner-rotation 800ms linear infinite; |
||||
} |
||||
|
||||
@keyframes spinner-rotation { |
||||
0% { |
||||
transform: rotate(0deg); |
||||
} |
||||
100% { |
||||
transform: rotate(360deg); |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
import "./Spinner.css"; |
||||
|
||||
interface SpinnerProps { |
||||
className?: string; |
||||
} |
||||
|
||||
export function Spinner(props: SpinnerProps) { |
||||
return <div className={["spinner", props.className ?? ""].join(" ")} />; |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
import { ReactNode } from "react"; |
||||
|
||||
interface WideContainerProps { |
||||
classNames?: string; |
||||
children?: ReactNode; |
||||
} |
||||
|
||||
export function WideContainer(props: WideContainerProps) { |
||||
return ( |
||||
<div |
||||
className={`mx-auto w-[700px] max-w-full px-8 sm:px-4 ${ |
||||
props.classNames || "" |
||||
}`}
|
||||
> |
||||
{props.children} |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,97 +1,129 @@
@@ -1,97 +1,129 @@
|
||||
import { Link } from "react-router-dom"; |
||||
import { |
||||
convertMediaToPortable, |
||||
getProviderFromId, |
||||
MWMediaMeta, |
||||
MWMediaType, |
||||
} from "@/providers"; |
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { serializePortableMedia } from "@/hooks/usePortableMedia"; |
||||
import { useTranslation } from "react-i18next"; |
||||
import { DotList } from "@/components/text/DotList"; |
||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||
import { JWMediaToId } from "@/backend/metadata/justwatch"; |
||||
import { Icons } from "../Icon"; |
||||
import { IconPatch } from "../buttons/IconPatch"; |
||||
|
||||
export interface MediaCardProps { |
||||
media: MWMediaMeta; |
||||
watchedPercentage: number; |
||||
linkable?: boolean; |
||||
series?: boolean; |
||||
series?: { |
||||
episode: number; |
||||
season: number; |
||||
episodeId: string; |
||||
seasonId: string; |
||||
}; |
||||
percentage?: number; |
||||
closable?: boolean; |
||||
onClose?: () => void; |
||||
} |
||||
|
||||
function MediaCardContent({ |
||||
media, |
||||
linkable, |
||||
watchedPercentage, |
||||
series, |
||||
percentage, |
||||
closable, |
||||
onClose, |
||||
}: MediaCardProps) { |
||||
const provider = getProviderFromId(media.providerId); |
||||
const { t } = useTranslation(); |
||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; |
||||
|
||||
if (!provider) { |
||||
return null; |
||||
} |
||||
const canLink = linkable && !closable; |
||||
|
||||
return ( |
||||
<article |
||||
className={`group relative mb-4 flex overflow-hidden rounded bg-denim-300 py-4 px-5 ${ |
||||
linkable ? "hover:bg-denim-400" : "" |
||||
<div |
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${ |
||||
canLink ? "hover:bg-opacity-100" : "" |
||||
}`}
|
||||
> |
||||
{/* progress background */} |
||||
{watchedPercentage > 0 ? ( |
||||
<div className="absolute top-0 left-0 right-0 bottom-0"> |
||||
<article |
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${ |
||||
canLink ? "group-hover:scale-95" : "" |
||||
}`}
|
||||
> |
||||
<div |
||||
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg" |
||||
style={{ |
||||
backgroundImage: media.poster ? `url(${media.poster})` : undefined, |
||||
}} |
||||
> |
||||
{series ? ( |
||||
<div className="absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors group-hover:bg-denim-500"> |
||||
<p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white"> |
||||
{t("seasons.seasonAndEpisode", { |
||||
season: series.season, |
||||
episode: series.episode, |
||||
})} |
||||
</p> |
||||
</div> |
||||
) : null} |
||||
|
||||
{percentage !== undefined ? ( |
||||
<> |
||||
<div |
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${ |
||||
canLink ? "group-hover:from-denim-100" : "" |
||||
}`}
|
||||
/> |
||||
<div |
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${ |
||||
canLink ? "group-hover:from-denim-100" : "" |
||||
}`}
|
||||
/> |
||||
<div className="absolute inset-x-0 bottom-0 p-3"> |
||||
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600"> |
||||
<div |
||||
className="absolute inset-y-0 left-0 rounded-full bg-bink-700" |
||||
style={{ |
||||
width: percentageString, |
||||
}} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</> |
||||
) : null} |
||||
|
||||
<div |
||||
className="relative h-full bg-bink-300 bg-opacity-30" |
||||
style={{ |
||||
width: `${watchedPercentage}%`, |
||||
}} |
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${ |
||||
closable ? "opacity-100" : "pointer-events-none opacity-0" |
||||
}`}
|
||||
> |
||||
<div className="absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l from-bink-400 to-transparent opacity-40" /> |
||||
<IconPatch |
||||
clickable |
||||
className="text-2xl text-slate-400" |
||||
onClick={() => closable && onClose?.()} |
||||
icon={Icons.X} |
||||
/> |
||||
</div> |
||||
</div> |
||||
) : null} |
||||
|
||||
<div className="relative flex flex-1"> |
||||
{/* card content */} |
||||
<div className="flex-1"> |
||||
<h1 className="mb-1 font-bold text-white"> |
||||
{media.title} |
||||
{series && media.seasonId && media.episodeId ? ( |
||||
<span className="ml-2 text-xs text-denim-700"> |
||||
S{media.seasonId} E{media.episodeId} |
||||
</span> |
||||
) : null} |
||||
</h1> |
||||
<DotList |
||||
className="text-xs" |
||||
content={[provider.displayName, media.mediaType, media.year]} |
||||
/> |
||||
</div> |
||||
|
||||
{/* hoverable chevron */} |
||||
<div |
||||
className={`flex translate-x-3 items-center justify-end text-xl text-white opacity-0 transition-[opacity,transform] ${ |
||||
linkable ? "group-hover:translate-x-0 group-hover:opacity-100" : "" |
||||
}`}
|
||||
> |
||||
<Icon icon={Icons.CHEVRON_RIGHT} /> |
||||
</div> |
||||
</div> |
||||
</article> |
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3"> |
||||
<span>{media.title}</span> |
||||
</h1> |
||||
<DotList |
||||
className="text-xs" |
||||
content={[t(`media.${media.type}`), media.year]} |
||||
/> |
||||
</article> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function MediaCard(props: MediaCardProps) { |
||||
let link = "movie"; |
||||
if (props.media.mediaType === MWMediaType.SERIES) link = "series"; |
||||
|
||||
const content = <MediaCardContent {...props} />; |
||||
|
||||
const canLink = props.linkable && !props.closable; |
||||
|
||||
let link = canLink |
||||
? `/media/${encodeURIComponent(JWMediaToId(props.media))}` |
||||
: "#"; |
||||
if (canLink && props.series) |
||||
link += `/${encodeURIComponent(props.series.seasonId)}/${encodeURIComponent( |
||||
props.series.episodeId |
||||
)}`;
|
||||
|
||||
if (!props.linkable) return <span>{content}</span>; |
||||
return ( |
||||
<Link |
||||
to={`/media/${link}/${serializePortableMedia( |
||||
convertMediaToPortable(props.media) |
||||
)}`}
|
||||
> |
||||
{content} |
||||
</Link> |
||||
); |
||||
return <Link to={link}>{content}</Link>; |
||||
} |
||||
|
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
import { forwardRef } from "react"; |
||||
|
||||
interface MediaGridProps { |
||||
children?: React.ReactNode; |
||||
} |
||||
|
||||
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>( |
||||
(props, ref) => { |
||||
return ( |
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}> |
||||
{props.children} |
||||
</div> |
||||
); |
||||
} |
||||
); |
||||
@ -1,109 +0,0 @@
@@ -1,109 +0,0 @@
|
||||
import { ReactElement, useEffect, useRef, useState } from "react"; |
||||
import Hls from "hls.js"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { Loading } from "@/components/layout/Loading"; |
||||
import { MWMediaCaption, MWMediaStream } from "@/providers"; |
||||
|
||||
export interface VideoPlayerProps { |
||||
source: MWMediaStream; |
||||
captions: MWMediaCaption[]; |
||||
startAt?: number; |
||||
onProgress?: (event: ProgressEvent) => void; |
||||
} |
||||
|
||||
export function SkeletonVideoPlayer(props: { error?: boolean }) { |
||||
return ( |
||||
<div className="flex aspect-video w-full items-center justify-center bg-denim-200 lg:rounded-xl"> |
||||
{props.error ? ( |
||||
<div className="flex flex-col items-center"> |
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" /> |
||||
<p className="mt-5 text-white">Couldn't get your stream</p> |
||||
</div> |
||||
) : ( |
||||
<div className="flex flex-col items-center"> |
||||
<Loading /> |
||||
<p className="mt-3 text-white">Getting your stream...</p> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function VideoPlayer(props: VideoPlayerProps) { |
||||
const videoRef = useRef<HTMLVideoElement | null>(null); |
||||
const [hasErrored, setErrored] = useState(false); |
||||
const [isLoading, setLoading] = useState(true); |
||||
const showVideo = !isLoading && !hasErrored; |
||||
const mustUseHls = props.source.type === "m3u8"; |
||||
|
||||
// reset if stream url changes
|
||||
useEffect(() => { |
||||
setLoading(true); |
||||
setErrored(false); |
||||
|
||||
// hls support
|
||||
if (mustUseHls) { |
||||
if (!videoRef.current) return; |
||||
|
||||
if (!Hls.isSupported()) { |
||||
setLoading(false); |
||||
setErrored(true); |
||||
return; |
||||
} |
||||
|
||||
const hls = new Hls(); |
||||
|
||||
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) { |
||||
videoRef.current.src = props.source.url; |
||||
return; |
||||
} |
||||
|
||||
hls.attachMedia(videoRef.current); |
||||
hls.loadSource(props.source.url); |
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => { |
||||
setErrored(true); |
||||
console.error(data); |
||||
}); |
||||
} |
||||
}, [props.source.url, videoRef, mustUseHls]); |
||||
|
||||
let skeletonUi: null | ReactElement = null; |
||||
if (hasErrored) { |
||||
skeletonUi = <SkeletonVideoPlayer error />; |
||||
} else if (isLoading) { |
||||
skeletonUi = <SkeletonVideoPlayer />; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
{skeletonUi} |
||||
<video |
||||
className={`w-full rounded-xl bg-black ${!showVideo ? "hidden" : ""}`} |
||||
ref={videoRef} |
||||
onProgress={(e) => |
||||
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent) |
||||
} |
||||
onLoadedData={(e) => { |
||||
setLoading(false); |
||||
if (props.startAt) |
||||
(e.target as HTMLVideoElement).currentTime = props.startAt; |
||||
}} |
||||
onError={(e) => { |
||||
console.error("failed to playback stream", e); |
||||
setErrored(true); |
||||
}} |
||||
controls |
||||
autoPlay |
||||
> |
||||
{!mustUseHls ? ( |
||||
<source src={props.source.url} type="video/mp4" /> |
||||
) : null} |
||||
{props.captions.map((v) => ( |
||||
<track key={v.id} kind="captions" label={v.label} src={v.url} /> |
||||
))} |
||||
</video> |
||||
</> |
||||
); |
||||
} |
||||
@ -1,25 +0,0 @@
@@ -1,25 +0,0 @@
|
||||
import { getEpisodeFromMedia, MWMedia } from "@/providers"; |
||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched"; |
||||
import { Episode } from "./EpisodeButton"; |
||||
|
||||
export interface WatchedEpisodeProps { |
||||
media: MWMedia; |
||||
onClick?: () => void; |
||||
active?: boolean; |
||||
} |
||||
|
||||
export function WatchedEpisode(props: WatchedEpisodeProps) { |
||||
const { watched } = useWatchedContext(); |
||||
const foundWatched = getWatchedFromPortable(watched.items, props.media); |
||||
const episode = getEpisodeFromMedia(props.media); |
||||
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0; |
||||
|
||||
return ( |
||||
<Episode |
||||
progress={watchedPercentage} |
||||
episodeNumber={episode?.episode?.sort ?? 1} |
||||
active={props.active} |
||||
onClick={props.onClick} |
||||
/> |
||||
); |
||||
} |
||||
@ -1,23 +1,44 @@
@@ -1,23 +1,44 @@
|
||||
import { MWMediaMeta } from "@/providers"; |
||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched"; |
||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||
import { useWatchedContext } from "@/state/watched"; |
||||
import { useMemo } from "react"; |
||||
import { MediaCard } from "./MediaCard"; |
||||
|
||||
export interface WatchedMediaCardProps { |
||||
media: MWMediaMeta; |
||||
series?: boolean; |
||||
closable?: boolean; |
||||
onClose?: () => void; |
||||
} |
||||
|
||||
function formatSeries( |
||||
obj: |
||||
| { episodeId: string; seasonId: string; episode: number; season: number } |
||||
| undefined |
||||
) { |
||||
if (!obj) return undefined; |
||||
return { |
||||
season: obj.season, |
||||
episode: obj.episode, |
||||
episodeId: obj.episodeId, |
||||
seasonId: obj.seasonId, |
||||
}; |
||||
} |
||||
|
||||
export function WatchedMediaCard(props: WatchedMediaCardProps) { |
||||
const { watched } = useWatchedContext(); |
||||
const foundWatched = getWatchedFromPortable(watched.items, props.media); |
||||
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0; |
||||
const watchedMedia = useMemo(() => { |
||||
return watched.items |
||||
.sort((a, b) => b.watchedAt - a.watchedAt) |
||||
.find((v) => v.item.meta.id === props.media.id); |
||||
}, [watched, props.media]); |
||||
|
||||
return ( |
||||
<MediaCard |
||||
watchedPercentage={watchedPercentage} |
||||
media={props.media} |
||||
series={props.series && props.media.episodeId !== undefined} |
||||
series={formatSeries(watchedMedia?.item?.series)} |
||||
linkable |
||||
percentage={watchedMedia?.percentage} |
||||
onClose={props.onClose} |
||||
closable={props.closable} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
@ -1,7 +0,0 @@
@@ -1,7 +0,0 @@
|
||||
export interface TaglineProps { |
||||
children?: React.ReactNode; |
||||
} |
||||
|
||||
export function Tagline(props: TaglineProps) { |
||||
return <p className="font-bold text-bink-600">{props.children}</p>; |
||||
} |
||||
@ -1,7 +1,16 @@
@@ -1,7 +1,16 @@
|
||||
export interface TitleProps { |
||||
children?: React.ReactNode; |
||||
className?: string; |
||||
} |
||||
|
||||
export function Title(props: TitleProps) { |
||||
return <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white">{props.children}</h1>; |
||||
return ( |
||||
<h1 |
||||
className={`text-2xl font-bold text-white sm:text-3xl md:text-4xl ${ |
||||
props.className ?? "" |
||||
}`}
|
||||
> |
||||
{props.children} |
||||
</h1> |
||||
); |
||||
} |
||||
|
||||
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb"; |
||||
export const GITHUB_LINK = "https://github.com/JamesHawkinss/movie-web"; |
||||
export const APP_VERSION = "2.1.0"; |
||||
@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
|
||||
/// <reference types="chromecast-caf-sender"/>
|
||||
|
||||
import { isChromecastAvailable } from "@/setup/chromecast"; |
||||
import { useEffect, useRef, useState } from "react"; |
||||
|
||||
export function useChromecastAvailable() { |
||||
const [available, setAvailable] = useState<boolean | null>(null); |
||||
|
||||
useEffect(() => { |
||||
isChromecastAvailable((bool) => setAvailable(bool)); |
||||
}, []); |
||||
|
||||
return available; |
||||
} |
||||
|
||||
export function useChromecast() { |
||||
const available = useChromecastAvailable(); |
||||
const instance = useRef<cast.framework.CastContext | null>(null); |
||||
const remotePlayerController = |
||||
useRef<cast.framework.RemotePlayerController | null>(null); |
||||
|
||||
function startCast() { |
||||
const movieMeta = new chrome.cast.media.MovieMediaMetadata(); |
||||
movieMeta.title = "Big Buck Bunny"; |
||||
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo("hello", "video/mp4"); |
||||
(mediaInfo as any).contentUrl = |
||||
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; |
||||
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; |
||||
mediaInfo.metadata = movieMeta; |
||||
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo); |
||||
request.autoplay = true; |
||||
|
||||
const session = instance.current?.getCurrentSession(); |
||||
console.log("testing", session); |
||||
if (!session) return; |
||||
|
||||
session |
||||
.loadMedia(request) |
||||
.then(() => { |
||||
console.log("Media is loaded"); |
||||
}) |
||||
.catch((e: any) => { |
||||
console.error(e); |
||||
}); |
||||
} |
||||
|
||||
function stopCast() { |
||||
const session = instance.current?.getCurrentSession(); |
||||
if (!session) return; |
||||
|
||||
const controller = remotePlayerController.current; |
||||
if (!controller) return; |
||||
controller.stop(); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if (!available) return; |
||||
|
||||
// setup instance if not already
|
||||
if (!instance.current) { |
||||
const ins = cast.framework.CastContext.getInstance(); |
||||
ins.setOptions({ |
||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, |
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, |
||||
}); |
||||
instance.current = ins; |
||||
} |
||||
|
||||
// setup player if not already
|
||||
if (!remotePlayerController.current) { |
||||
const player = new cast.framework.RemotePlayer(); |
||||
const controller = new cast.framework.RemotePlayerController(player); |
||||
remotePlayerController.current = controller; |
||||
} |
||||
|
||||
// setup event listener
|
||||
function listenToEvents(e: cast.framework.RemotePlayerChangedEvent) { |
||||
console.log("chromecast event", e); |
||||
} |
||||
function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) { |
||||
console.log("chromecast event connection changed", e); |
||||
} |
||||
remotePlayerController.current.addEventListener( |
||||
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED, |
||||
listenToEvents |
||||
); |
||||
remotePlayerController.current.addEventListener( |
||||
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, |
||||
connectionChanged |
||||
); |
||||
|
||||
return () => { |
||||
remotePlayerController.current?.removeEventListener( |
||||
cast.framework.RemotePlayerEventType.PLAYER_STATE_CHANGED, |
||||
listenToEvents |
||||
); |
||||
remotePlayerController.current?.removeEventListener( |
||||
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, |
||||
connectionChanged |
||||
); |
||||
}; |
||||
}, [available]); |
||||
|
||||
return { |
||||
startCast, |
||||
stopCast, |
||||
}; |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
import { useCallback } from "react"; |
||||
import { useHistory } from "react-router-dom"; |
||||
|
||||
export function useGoBack() { |
||||
const reactHistory = useHistory(); |
||||
|
||||
const goBack = useCallback(() => { |
||||
if (reactHistory.action !== "POP") reactHistory.goBack(); |
||||
else reactHistory.push("/"); |
||||
}, [reactHistory]); |
||||
return goBack; |
||||
} |
||||
@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useRef, useState } from "react"; |
||||
|
||||
export function useIsMobile() { |
||||
const [isMobile, setIsMobile] = useState(false); |
||||
const isMobileCurrent = useRef<boolean | null>(false); |
||||
|
||||
useEffect(() => { |
||||
function onResize() { |
||||
const value = window.innerWidth < 1024; |
||||
const isChanged = isMobileCurrent.current !== value; |
||||
if (!isChanged) return; |
||||
|
||||
isMobileCurrent.current = value; |
||||
setIsMobile(value); |
||||
} |
||||
|
||||
onResize(); |
||||
window.addEventListener("resize", onResize); |
||||
|
||||
return () => { |
||||
window.removeEventListener("resize", onResize); |
||||
}; |
||||
}, []); |
||||
|
||||
return { |
||||
isMobile, |
||||
}; |
||||
} |
||||
@ -1,30 +0,0 @@
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useState } from "react"; |
||||
import { useParams } from "react-router-dom"; |
||||
import { MWPortableMedia } from "@/providers"; |
||||
|
||||
export function deserializePortableMedia(media: string): MWPortableMedia { |
||||
return JSON.parse(atob(decodeURIComponent(media))); |
||||
} |
||||
|
||||
export function serializePortableMedia(media: MWPortableMedia): string { |
||||
const data = encodeURIComponent(btoa(JSON.stringify(media))); |
||||
return data; |
||||
} |
||||
|
||||
export function usePortableMedia(): MWPortableMedia | undefined { |
||||
const { media } = useParams<{ media: string }>(); |
||||
const [mediaObject, setMediaObject] = useState<MWPortableMedia | undefined>( |
||||
undefined |
||||
); |
||||
|
||||
useEffect(() => { |
||||
try { |
||||
setMediaObject(deserializePortableMedia(media)); |
||||
} catch (err) { |
||||
console.error("Failed to deserialize portable media", err); |
||||
setMediaObject(undefined); |
||||
} |
||||
}, [media, setMediaObject]); |
||||
|
||||
return mediaObject; |
||||
} |
||||
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
import React, { RefObject, useCallback, useEffect, useState } from "react"; |
||||
|
||||
type ActivityEvent = |
||||
| React.MouseEvent<HTMLElement> |
||||
| React.TouchEvent<HTMLElement> |
||||
| MouseEvent |
||||
| TouchEvent; |
||||
|
||||
export function makePercentageString(num: number) { |
||||
return `${num.toFixed(2)}%`; |
||||
} |
||||
|
||||
export function makePercentage(num: number) { |
||||
return Number(Math.max(0, Math.min(num, 100)).toFixed(2)); |
||||
} |
||||
|
||||
function isClickEvent( |
||||
evt: ActivityEvent |
||||
): evt is React.MouseEvent<HTMLElement> | MouseEvent { |
||||
return ( |
||||
evt.type === "mousedown" || |
||||
evt.type === "mouseup" || |
||||
evt.type === "mousemove" |
||||
); |
||||
} |
||||
|
||||
const getEventX = (evt: ActivityEvent) => { |
||||
return isClickEvent(evt) ? evt.pageX : evt.changedTouches[0].pageX; |
||||
}; |
||||
|
||||
export function useProgressBar( |
||||
barRef: RefObject<HTMLElement>, |
||||
commit: (percentage: number) => void, |
||||
commitImmediately = false |
||||
) { |
||||
const [mouseDown, setMouseDown] = useState<boolean>(false); |
||||
const [progress, setProgress] = useState<number>(0); |
||||
|
||||
useEffect(() => { |
||||
function mouseMove(ev: ActivityEvent) { |
||||
if (!mouseDown || !barRef.current) return; |
||||
const rect = barRef.current.getBoundingClientRect(); |
||||
const pos = (getEventX(ev) - rect.left) / barRef.current.offsetWidth; |
||||
setProgress(pos * 100); |
||||
if (commitImmediately) commit(pos); |
||||
} |
||||
|
||||
function mouseUp(ev: ActivityEvent) { |
||||
if (!mouseDown) return; |
||||
setMouseDown(false); |
||||
document.body.removeAttribute("data-no-select"); |
||||
|
||||
if (!barRef.current) return; |
||||
const rect = barRef.current.getBoundingClientRect(); |
||||
const pos = (getEventX(ev) - rect.left) / barRef.current.offsetWidth; |
||||
commit(pos); |
||||
} |
||||
|
||||
document.addEventListener("mousemove", mouseMove); |
||||
document.addEventListener("touchmove", mouseMove); |
||||
document.addEventListener("mouseup", mouseUp); |
||||
document.addEventListener("touchend", mouseUp); |
||||
|
||||
return () => { |
||||
document.removeEventListener("mousemove", mouseMove); |
||||
document.removeEventListener("touchmove", mouseMove); |
||||
document.removeEventListener("mouseup", mouseUp); |
||||
document.removeEventListener("touchend", mouseUp); |
||||
}; |
||||
}, [mouseDown, barRef, commit, commitImmediately]); |
||||
|
||||
const dragMouseDown = useCallback( |
||||
(ev: ActivityEvent) => { |
||||
setMouseDown(true); |
||||
document.body.setAttribute("data-no-select", "true"); |
||||
|
||||
if (!barRef.current) return; |
||||
const rect = barRef.current.getBoundingClientRect(); |
||||
const pos = |
||||
((getEventX(ev) - rect.left) / barRef.current.offsetWidth) * 100; |
||||
setProgress(pos); |
||||
}, |
||||
[setProgress, barRef] |
||||
); |
||||
|
||||
return { |
||||
dragging: mouseDown, |
||||
dragPercentage: progress, |
||||
dragMouseDown, |
||||
}; |
||||
} |
||||
@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
|
||||
import { findBestStream } from "@/backend/helpers/scrape"; |
||||
import { MWStream } from "@/backend/helpers/streams"; |
||||
import { DetailedMeta } from "@/backend/metadata/getmeta"; |
||||
import { MWMediaType } from "@/backend/metadata/types"; |
||||
import { useEffect, useState } from "react"; |
||||
|
||||
export interface ScrapeEventLog { |
||||
type: "provider" | "embed"; |
||||
errored: boolean; |
||||
percentage: number; |
||||
eventId: string; |
||||
id: string; |
||||
} |
||||
|
||||
export type SelectedMediaData = |
||||
| { |
||||
type: MWMediaType.SERIES; |
||||
episode: string; |
||||
season: string; |
||||
} |
||||
| { |
||||
type: MWMediaType.MOVIE | MWMediaType.ANIME; |
||||
episode: undefined; |
||||
season: undefined; |
||||
}; |
||||
|
||||
export function useScrape(meta: DetailedMeta, selected: SelectedMediaData) { |
||||
const [eventLog, setEventLog] = useState<ScrapeEventLog[]>([]); |
||||
const [stream, setStream] = useState<MWStream | null>(null); |
||||
const [pending, setPending] = useState(true); |
||||
|
||||
useEffect(() => { |
||||
setPending(true); |
||||
setStream(null); |
||||
setEventLog([]); |
||||
(async () => { |
||||
const scrapedStream = await findBestStream({ |
||||
media: meta, |
||||
...selected, |
||||
onNext(ctx) { |
||||
setEventLog((arr) => [ |
||||
...arr, |
||||
{ |
||||
errored: false, |
||||
id: ctx.id, |
||||
eventId: ctx.eventId, |
||||
type: ctx.type, |
||||
percentage: 0, |
||||
}, |
||||
]); |
||||
}, |
||||
onProgress(ctx) { |
||||
setEventLog((arr) => { |
||||
const item = arr.reverse().find((v) => v.id === ctx.id); |
||||
if (item) { |
||||
item.errored = ctx.errored; |
||||
item.percentage = ctx.percentage; |
||||
} |
||||
return [...arr]; |
||||
}); |
||||
}, |
||||
}); |
||||
|
||||
setPending(false); |
||||
setStream(scrapedStream); |
||||
})(); |
||||
}, [meta, selected]); |
||||
|
||||
return { |
||||
stream, |
||||
pending, |
||||
eventLog, |
||||
}; |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
import { useControls } from "@/video/state/logic/controls"; |
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; |
||||
import { useState } from "react"; |
||||
|
||||
export function useVolumeControl(descriptor: string) { |
||||
const [storedVolume, setStoredVolume] = useState(1); |
||||
const controls = useControls(descriptor); |
||||
const mediaPlaying = useMediaPlaying(descriptor); |
||||
|
||||
const toggleVolume = () => { |
||||
if (mediaPlaying.volume > 0) { |
||||
setStoredVolume(mediaPlaying.volume); |
||||
controls.setVolume(0); |
||||
} else { |
||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1); |
||||
} |
||||
}; |
||||
|
||||
return { |
||||
storedVolume, |
||||
setStoredVolume, |
||||
toggleVolume, |
||||
}; |
||||
} |
||||
@ -1,28 +0,0 @@
@@ -1,28 +0,0 @@
|
||||
import i18n from 'i18next'; |
||||
import { initReactI18next } from 'react-i18next'; |
||||
|
||||
import Backend from 'i18next-http-backend'; |
||||
import LanguageDetector from 'i18next-browser-languagedetector'; |
||||
|
||||
i18n |
||||
// load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales)
|
||||
// learn more: https://github.com/i18next/i18next-http-backend
|
||||
// want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn
|
||||
.use(Backend) |
||||
// detect user language
|
||||
// learn more: https://github.com/i18next/i18next-browser-languageDetector
|
||||
.use(LanguageDetector) |
||||
// pass the i18n instance to react-i18next.
|
||||
.use(initReactI18next) |
||||
// init i18next
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({ |
||||
fallbackLng: 'en-GB', |
||||
|
||||
interpolation: { |
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
} |
||||
}); |
||||
|
||||
|
||||
export default i18n; |
||||
@ -1,16 +0,0 @@
@@ -1,16 +0,0 @@
|
||||
@tailwind base; |
||||
@tailwind components; |
||||
@tailwind utilities; |
||||
|
||||
html, |
||||
body { |
||||
@apply bg-denim-100 text-denim-700 font-open-sans min-h-screen; |
||||
} |
||||
|
||||
#root { |
||||
display: flex; |
||||
justify-content: flex-start; |
||||
align-items: flex-start; |
||||
min-height: 100vh; |
||||
width: 100%; |
||||
} |
||||
@ -1,31 +0,0 @@
@@ -1,31 +0,0 @@
|
||||
# the providers |
||||
|
||||
to make this as clear as possible, here is some extra information on how the interal system works regarding providers. |
||||
|
||||
| Term | explanation | |
||||
| ------------- | ------------------------------------------------------------------------------------- | |
||||
| Media | Object containing information about a piece of media. like title and its id's | |
||||
| PortableMedia | Object with just the identifiers of a piece of media. used for transport and saving | |
||||
| MediaStream | Object with a stream url in it. use it to view a piece of media. | |
||||
| Provider | group of methods to generate media and mediastreams from a source. aliased as scraper | |
||||
|
||||
All types are prefixed with MW (MovieWeb) to prevent clashing names. |
||||
|
||||
## Some rules |
||||
|
||||
1. **Never** remove a provider completely if it's been in use before. just disable it. |
||||
2. **Never** change the ID of a provider if it's been in use before. |
||||
3. **Never** change system of the media ID of a provider without making it backwards compatible |
||||
|
||||
All these rules are because `PortableMedia` objects need to stay functional. because: |
||||
|
||||
- It's used for routing, links would stop working |
||||
- It's used for storage, continue watching and bookmarks would stop working |
||||
|
||||
# The list of providers and their quirks |
||||
|
||||
Some providers have quirks, stuff they do differently than other providers |
||||
|
||||
## TheFlix |
||||
|
||||
- for series, the latest episode released will be one playing at first when you select it from search results |
||||
@ -1,42 +0,0 @@
@@ -1,42 +0,0 @@
|
||||
import { getProviderFromId } from "./methods/helpers"; |
||||
import { MWMedia, MWPortableMedia, MWMediaStream } from "./types"; |
||||
|
||||
export * from "./types"; |
||||
export * from "./methods/helpers"; |
||||
export * from "./methods/providers"; |
||||
export * from "./methods/search"; |
||||
|
||||
/* |
||||
** Turn media object into a portable media object |
||||
*/ |
||||
export function convertMediaToPortable(media: MWMedia): MWPortableMedia { |
||||
return { |
||||
mediaId: media.mediaId, |
||||
providerId: media.providerId, |
||||
mediaType: media.mediaType, |
||||
episodeId: media.episodeId, |
||||
seasonId: media.seasonId, |
||||
}; |
||||
} |
||||
|
||||
/* |
||||
** Turn portable media into media object |
||||
*/ |
||||
export async function convertPortableToMedia( |
||||
portable: MWPortableMedia |
||||
): Promise<MWMedia | undefined> { |
||||
const provider = getProviderFromId(portable.providerId); |
||||
return provider?.getMediaFromPortable(portable); |
||||
} |
||||
|
||||
/* |
||||
** find provider from portable and get stream from that provider |
||||
*/ |
||||
export async function getStream( |
||||
media: MWPortableMedia |
||||
): Promise<MWMediaStream | undefined> { |
||||
const provider = getProviderFromId(media.providerId); |
||||
if (!provider) return undefined; |
||||
|
||||
return provider.getStream(media); |
||||
} |
||||
@ -1,89 +0,0 @@
@@ -1,89 +0,0 @@
|
||||
import { |
||||
MWMediaProvider, |
||||
MWMediaType, |
||||
MWPortableMedia, |
||||
MWMediaStream, |
||||
MWQuery, |
||||
MWProviderMediaResult, |
||||
} from "@/providers/types"; |
||||
|
||||
import { conf } from "@/config"; |
||||
|
||||
export const flixhqProvider: MWMediaProvider = { |
||||
id: "flixhq", |
||||
enabled: true, |
||||
type: [MWMediaType.MOVIE], |
||||
displayName: "flixhq", |
||||
|
||||
async getMediaFromPortable( |
||||
media: MWPortableMedia |
||||
): Promise<MWProviderMediaResult> { |
||||
const searchRes = await fetch( |
||||
`${ |
||||
conf().CORS_PROXY_URL |
||||
}https://api.consumet.org/movies/flixhq/info?id=${encodeURIComponent(
|
||||
media.mediaId |
||||
)}` |
||||
).then((d) => d.json()); |
||||
|
||||
return { |
||||
...media, |
||||
title: searchRes.title, |
||||
year: searchRes.releaseDate, |
||||
} as MWProviderMediaResult; |
||||
}, |
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> { |
||||
const searchRes = await fetch( |
||||
`${ |
||||
conf().CORS_PROXY_URL |
||||
}https://api.consumet.org/movies/flixhq/${encodeURIComponent(
|
||||
query.searchQuery |
||||
)}` |
||||
).then((d) => d.json()); |
||||
|
||||
const results: MWProviderMediaResult[] = (searchRes || []).results.map( |
||||
(item: any) => ({ |
||||
title: item.title, |
||||
year: item.releaseDate, |
||||
mediaId: item.id, |
||||
type: MWMediaType.MOVIE, |
||||
}) |
||||
); |
||||
|
||||
return results; |
||||
}, |
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> { |
||||
const searchRes = await fetch( |
||||
`${ |
||||
conf().CORS_PROXY_URL |
||||
}https://api.consumet.org/movies/flixhq/info?id=${encodeURIComponent(
|
||||
media.mediaId |
||||
)}` |
||||
).then((d) => d.json()); |
||||
|
||||
const params = new URLSearchParams({ |
||||
episodeId: searchRes.episodes[0].id, |
||||
mediaId: media.mediaId, |
||||
}); |
||||
|
||||
const watchRes = await fetch( |
||||
`${ |
||||
conf().CORS_PROXY_URL |
||||
}https://api.consumet.org/movies/flixhq/watch?${encodeURIComponent(
|
||||
params.toString() |
||||
)}` |
||||
).then((d) => d.json()); |
||||
|
||||
const source = watchRes.sources.reduce((p: any, c: any) => |
||||
c.quality > p.quality ? c : p |
||||
); |
||||
|
||||
return { |
||||
url: source.url, |
||||
type: source.isM3U8 ? "m3u8" : "mp4", |
||||
captions: [], |
||||
} as MWMediaStream; |
||||
}, |
||||
}; |
||||
@ -1,125 +0,0 @@
@@ -1,125 +0,0 @@
|
||||
import { unpack } from "unpacker"; |
||||
import json5 from "json5"; |
||||
import { |
||||
MWMediaProvider, |
||||
MWMediaType, |
||||
MWPortableMedia, |
||||
MWMediaStream, |
||||
MWQuery, |
||||
MWProviderMediaResult, |
||||
} from "@/providers/types"; |
||||
|
||||
import { conf } from "@/config"; |
||||
|
||||
export const gomostreamScraper: MWMediaProvider = { |
||||
id: "gomostream", |
||||
enabled: true, |
||||
type: [MWMediaType.MOVIE], |
||||
displayName: "gomostream", |
||||
|
||||
async getMediaFromPortable( |
||||
media: MWPortableMedia |
||||
): Promise<MWProviderMediaResult> { |
||||
const params = new URLSearchParams({ |
||||
apikey: conf().OMDB_API_KEY, |
||||
i: media.mediaId, |
||||
type: media.mediaType, |
||||
}); |
||||
|
||||
const res = await fetch( |
||||
`${conf().CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent( |
||||
params.toString() |
||||
)}` |
||||
).then((d) => d.json()); |
||||
|
||||
return { |
||||
...media, |
||||
title: res.Title, |
||||
year: res.Year, |
||||
} as MWProviderMediaResult; |
||||
}, |
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> { |
||||
const term = query.searchQuery.toLowerCase(); |
||||
|
||||
const params = new URLSearchParams({ |
||||
apikey: conf().OMDB_API_KEY, |
||||
s: term, |
||||
type: query.type, |
||||
}); |
||||
const searchRes = await fetch( |
||||
`${conf().CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent( |
||||
params.toString() |
||||
)}` |
||||
).then((d) => d.json()); |
||||
|
||||
const results: MWProviderMediaResult[] = (searchRes.Search || []).map( |
||||
(d: any) => |
||||
({ |
||||
title: d.Title, |
||||
year: d.Year, |
||||
mediaId: d.imdbID, |
||||
} as MWProviderMediaResult) |
||||
); |
||||
|
||||
return results; |
||||
}, |
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> { |
||||
const type = |
||||
media.mediaType === MWMediaType.SERIES ? "show" : media.mediaType; |
||||
const res1 = await fetch( |
||||
`${conf().CORS_PROXY_URL}https://gomo.to/${type}/${media.mediaId}` |
||||
).then((d) => d.text()); |
||||
if (res1 === "Movie not available." || res1 === "Episode not available.") |
||||
throw new Error(res1); |
||||
|
||||
const tc = res1.match(/var tc = '(.+)';/)?.[1] || ""; |
||||
const _token = res1.match(/"_token": "(.+)",/)?.[1] || ""; |
||||
|
||||
const fd = new FormData(); |
||||
fd.append("tokenCode", tc); |
||||
fd.append("_token", _token); |
||||
|
||||
const src = await fetch( |
||||
`${conf().CORS_PROXY_URL}https://gomo.to/decoding_v3.php`, |
||||
{ |
||||
method: "POST", |
||||
body: fd, |
||||
headers: { |
||||
"x-token": `${tc.slice(5, 13).split("").reverse().join("")}13574199`, |
||||
}, |
||||
} |
||||
).then((d) => d.json()); |
||||
const embeds = src.filter((url: string) => url.includes("gomo.to")); |
||||
|
||||
// maybe try all embeds in the future
|
||||
const embedUrl = embeds[1]; |
||||
const res2 = await fetch(`${conf().CORS_PROXY_URL}${embedUrl}`).then((d) => |
||||
d.text() |
||||
); |
||||
|
||||
const res2DOM = new DOMParser().parseFromString(res2, "text/html"); |
||||
if (res2DOM.body.innerText === "File was deleted") |
||||
throw new Error("File was deleted"); |
||||
|
||||
const script = Array.from(res2DOM.querySelectorAll("script")).find( |
||||
(s: HTMLScriptElement) => |
||||
s.innerHTML.includes("eval(function(p,a,c,k,e,d") |
||||
)?.innerHTML; |
||||
if (!script) throw new Error("Could not get packed data"); |
||||
|
||||
const unpacked = unpack(script); |
||||
const rawSources = /sources:(\[.*?\])/.exec(unpacked); |
||||
if (!rawSources) throw new Error("Could not get rawSources"); |
||||
|
||||
const sources = json5.parse(rawSources[1]); |
||||
const streamUrl = sources[0].file; |
||||
|
||||
const streamType = streamUrl.split(".").at(-1); |
||||
if (streamType !== "mp4" && streamType !== "m3u8") |
||||
throw new Error("Unsupported stream type"); |
||||
|
||||
return { url: streamUrl, type: streamType, captions: [] }; |
||||
}, |
||||
}; |
||||
@ -1,307 +0,0 @@
@@ -1,307 +0,0 @@
|
||||
// this is derived from https://github.com/recloudstream/cloudstream-extensions
|
||||
// for more info please check the LICENSE file in the same directory
|
||||
|
||||
import { customAlphabet } from "nanoid"; |
||||
import toWebVTT from "srt-webvtt"; |
||||
import CryptoJS from "crypto-js"; |
||||
import { conf } from "@/config"; |
||||
import { |
||||
MWMediaProvider, |
||||
MWMediaType, |
||||
MWPortableMedia, |
||||
MWMediaStream, |
||||
MWQuery, |
||||
MWMediaSeasons, |
||||
MWProviderMediaResult, |
||||
} from "@/providers/types"; |
||||
|
||||
const nanoid = customAlphabet("0123456789abcdef", 32); |
||||
|
||||
// CONSTANTS, read below (taken from og)
|
||||
// We do not want content scanners to notice this scraping going on so we've hidden all constants
|
||||
// The source has its origins in China so I added some extra security with banned words
|
||||
// Mayhaps a tiny bit unethical, but this source is just too good :)
|
||||
// If you are copying this code please use precautions so they do not change their api.
|
||||
const iv = atob("d0VpcGhUbiE="); |
||||
const key = atob("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2"); |
||||
const apiUrls = [ |
||||
atob("aHR0cHM6Ly9zaG93Ym94LnNoZWd1Lm5ldC9hcGkvYXBpX2NsaWVudC9pbmRleC8="), |
||||
atob("aHR0cHM6Ly9tYnBhcGkuc2hlZ3UubmV0L2FwaS9hcGlfY2xpZW50L2luZGV4Lw=="), |
||||
]; |
||||
const appKey = atob("bW92aWVib3g="); |
||||
const appId = atob("Y29tLnRkby5zaG93Ym94"); |
||||
|
||||
// cryptography stuff
|
||||
const crypto = { |
||||
encrypt(str: string) { |
||||
return CryptoJS.TripleDES.encrypt(str, CryptoJS.enc.Utf8.parse(key), { |
||||
iv: CryptoJS.enc.Utf8.parse(iv), |
||||
}).toString(); |
||||
}, |
||||
getVerify(str: string, str2: string, str3: string) { |
||||
if (str) { |
||||
return CryptoJS.MD5( |
||||
CryptoJS.MD5(str2).toString() + str3 + str |
||||
).toString(); |
||||
} |
||||
return null; |
||||
}, |
||||
}; |
||||
|
||||
// get expire time
|
||||
const expiry = () => Math.floor(Date.now() / 1000 + 60 * 60 * 12); |
||||
|
||||
// sending requests
|
||||
const get = (data: object, altApi = false) => { |
||||
const defaultData = { |
||||
childmode: "0", |
||||
app_version: "11.5", |
||||
appid: appId, |
||||
lang: "en", |
||||
expired_date: `${expiry()}`, |
||||
platform: "android", |
||||
channel: "Website", |
||||
}; |
||||
const encryptedData = crypto.encrypt( |
||||
JSON.stringify({ |
||||
...defaultData, |
||||
...data, |
||||
}) |
||||
); |
||||
const appKeyHash = CryptoJS.MD5(appKey).toString(); |
||||
const verify = crypto.getVerify(encryptedData, appKey, key); |
||||
const body = JSON.stringify({ |
||||
app_key: appKeyHash, |
||||
verify, |
||||
encrypt_data: encryptedData, |
||||
}); |
||||
const b64Body = btoa(body); |
||||
|
||||
const formatted = new URLSearchParams(); |
||||
formatted.append("data", b64Body); |
||||
formatted.append("appid", "27"); |
||||
formatted.append("platform", "android"); |
||||
formatted.append("version", "129"); |
||||
formatted.append("medium", "Website"); |
||||
|
||||
const requestUrl = altApi ? apiUrls[1] : apiUrls[0]; |
||||
return fetch(`${conf().CORS_PROXY_URL}${requestUrl}`, { |
||||
method: "POST", |
||||
headers: { |
||||
Platform: "android", |
||||
"Content-Type": "application/x-www-form-urlencoded", |
||||
}, |
||||
body: `${formatted.toString()}&token${nanoid()}`, |
||||
}); |
||||
}; |
||||
|
||||
export const superStreamScraper: MWMediaProvider = { |
||||
id: "superstream", |
||||
enabled: true, |
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
displayName: "SuperStream", |
||||
|
||||
async getMediaFromPortable( |
||||
media: MWPortableMedia |
||||
): Promise<MWProviderMediaResult> { |
||||
let apiQuery: any; |
||||
if (media.mediaType === MWMediaType.SERIES) { |
||||
apiQuery = { |
||||
module: "TV_detail_1", |
||||
display_all: "1", |
||||
tid: media.mediaId, |
||||
}; |
||||
} else { |
||||
apiQuery = { |
||||
module: "Movie_detail", |
||||
mid: media.mediaId, |
||||
}; |
||||
} |
||||
const detailRes = (await get(apiQuery, true).then((r) => r.json())).data; |
||||
|
||||
return { |
||||
...media, |
||||
title: detailRes.title, |
||||
year: detailRes.year, |
||||
seasonCount: detailRes?.season?.length, |
||||
} as MWProviderMediaResult; |
||||
}, |
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> { |
||||
const apiQuery = { |
||||
module: "Search3", |
||||
page: "1", |
||||
type: "all", |
||||
keyword: query.searchQuery, |
||||
pagelimit: "20", |
||||
}; |
||||
const searchRes = (await get(apiQuery, true).then((r) => r.json())).data; |
||||
|
||||
const movieResults: MWProviderMediaResult[] = (searchRes || []) |
||||
.filter((item: any) => item.box_type === 1) |
||||
.map((item: any) => ({ |
||||
title: item.title, |
||||
year: item.year, |
||||
mediaId: item.id, |
||||
})); |
||||
const seriesResults: MWProviderMediaResult[] = (searchRes || []) |
||||
.filter((item: any) => item.box_type === 2) |
||||
.map((item: any) => ({ |
||||
title: item.title, |
||||
year: item.year, |
||||
mediaId: item.id, |
||||
seasonId: "1", |
||||
episodeId: "1", |
||||
})); |
||||
|
||||
if (query.type === MWMediaType.MOVIE) { |
||||
return movieResults; |
||||
} |
||||
if (query.type === MWMediaType.SERIES) { |
||||
return seriesResults; |
||||
} |
||||
throw new Error("Invalid media type used."); |
||||
}, |
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> { |
||||
if (media.mediaType === MWMediaType.MOVIE) { |
||||
const apiQuery = { |
||||
uid: "", |
||||
module: "Movie_downloadurl_v3", |
||||
mid: media.mediaId, |
||||
oss: "1", |
||||
group: "", |
||||
}; |
||||
const mediaRes = (await get(apiQuery).then((r) => r.json())).data; |
||||
const hdQuality = |
||||
mediaRes.list.find( |
||||
(quality: any) => quality.quality === "1080p" && quality.path |
||||
) ?? |
||||
mediaRes.list.find( |
||||
(quality: any) => quality.quality === "720p" && quality.path |
||||
) ?? |
||||
mediaRes.list.find( |
||||
(quality: any) => quality.quality === "480p" && quality.path |
||||
) ?? |
||||
mediaRes.list.find( |
||||
(quality: any) => quality.quality === "360p" && quality.path |
||||
); |
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found."); |
||||
|
||||
const subtitleApiQuery = { |
||||
fid: hdQuality.fid, |
||||
uid: "", |
||||
module: "Movie_srt_list_v2", |
||||
mid: media.mediaId, |
||||
}; |
||||
const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json())) |
||||
.data; |
||||
const mappedCaptions = await Promise.all( |
||||
subtitleRes.list.map(async (subtitle: any) => { |
||||
const captionBlob = await fetch( |
||||
`${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}` |
||||
).then((captionRes) => captionRes.blob()); // cross-origin bypass
|
||||
const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable
|
||||
return { |
||||
id: subtitle.language, |
||||
url: captionUrl, |
||||
label: subtitle.language, |
||||
}; |
||||
}) |
||||
); |
||||
|
||||
return { url: hdQuality.path, type: "mp4", captions: mappedCaptions }; |
||||
} |
||||
|
||||
const apiQuery = { |
||||
uid: "", |
||||
module: "TV_downloadurl_v3", |
||||
episode: media.episodeId, |
||||
tid: media.mediaId, |
||||
season: media.seasonId, |
||||
oss: "1", |
||||
group: "", |
||||
}; |
||||
const mediaRes = (await get(apiQuery).then((r) => r.json())).data; |
||||
const hdQuality = |
||||
mediaRes.list.find( |
||||
(quality: any) => quality.quality === "1080p" && quality.path |
||||
) ?? |
||||
mediaRes.list.find( |
||||
(quality: any) => quality.quality === "720p" && quality.path |
||||
) ?? |
||||
mediaRes.list.find( |
||||
(quality: any) => quality.quality === "480p" && quality.path |
||||
) ?? |
||||
mediaRes.list.find( |
||||
(quality: any) => quality.quality === "360p" && quality.path |
||||
); |
||||
|
||||
if (!hdQuality) throw new Error("No quality could be found."); |
||||
|
||||
const subtitleApiQuery = { |
||||
fid: hdQuality.fid, |
||||
uid: "", |
||||
module: "TV_srt_list_v2", |
||||
episode: media.episodeId, |
||||
tid: media.mediaId, |
||||
season: media.seasonId, |
||||
}; |
||||
const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json())) |
||||
.data; |
||||
const mappedCaptions = await Promise.all( |
||||
subtitleRes.list.map(async (subtitle: any) => { |
||||
const captionBlob = await fetch( |
||||
`${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}` |
||||
).then((captionRes) => captionRes.blob()); // cross-origin bypass
|
||||
const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable
|
||||
return { |
||||
id: subtitle.language, |
||||
url: captionUrl, |
||||
label: subtitle.language, |
||||
}; |
||||
}) |
||||
); |
||||
|
||||
return { url: hdQuality.path, type: "mp4", captions: mappedCaptions }; |
||||
}, |
||||
async getSeasonDataFromMedia( |
||||
media: MWPortableMedia |
||||
): Promise<MWMediaSeasons> { |
||||
const apiQuery = { |
||||
module: "TV_detail_1", |
||||
display_all: "1", |
||||
tid: media.mediaId, |
||||
}; |
||||
const detailRes = (await get(apiQuery, true).then((r) => r.json())).data; |
||||
const firstSearchResult = ( |
||||
await fetch( |
||||
`https://api.themoviedb.org/3/search/tv?api_key=${ |
||||
conf().TMDB_API_KEY |
||||
}&language=en-US&page=1&query=${detailRes.title}&include_adult=false` |
||||
).then((r) => r.json()) |
||||
).results[0]; |
||||
const showDetails = await fetch( |
||||
`https://api.themoviedb.org/3/tv/${firstSearchResult.id}?api_key=${ |
||||
conf().TMDB_API_KEY |
||||
}` |
||||
).then((r) => r.json()); |
||||
|
||||
return { |
||||
seasons: showDetails.seasons.map((season: any) => ({ |
||||
sort: season.season_number, |
||||
id: season.season_number.toString(), |
||||
type: season.season_number === 0 ? "special" : "season", |
||||
episodes: Array.from({ length: season.episode_count }).map( |
||||
(_, epNum) => ({ |
||||
title: `Episode ${epNum + 1}`, |
||||
sort: epNum + 1, |
||||
id: (epNum + 1).toString(), |
||||
episodeNumber: epNum + 1, |
||||
}) |
||||
), |
||||
})), |
||||
}; |
||||
}, |
||||
}; |
||||
@ -1,119 +0,0 @@
@@ -1,119 +0,0 @@
|
||||
import { |
||||
MWMediaProvider, |
||||
MWMediaType, |
||||
MWPortableMedia, |
||||
MWMediaStream, |
||||
MWQuery, |
||||
MWMediaSeasons, |
||||
MWProviderMediaResult, |
||||
} from "@/providers/types"; |
||||
|
||||
import { |
||||
searchTheFlix, |
||||
getDataFromSearch, |
||||
turnDataIntoMedia, |
||||
} from "@/providers/list/theflix/search"; |
||||
|
||||
import { getDataFromPortableSearch } from "@/providers/list/theflix/portableToMedia"; |
||||
import { conf } from "@/config"; |
||||
|
||||
export const theFlixScraper: MWMediaProvider = { |
||||
id: "theflix", |
||||
enabled: false, |
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
displayName: "theflix", |
||||
|
||||
async getMediaFromPortable( |
||||
media: MWPortableMedia |
||||
): Promise<MWProviderMediaResult> { |
||||
const data: any = await getDataFromPortableSearch(media); |
||||
|
||||
return { |
||||
...media, |
||||
year: new Date(data.releaseDate).getFullYear().toString(), |
||||
title: data.name, |
||||
}; |
||||
}, |
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> { |
||||
const searchRes = await searchTheFlix(query); |
||||
const searchData = await getDataFromSearch(searchRes, 10); |
||||
|
||||
const results: MWProviderMediaResult[] = []; |
||||
for (const item of searchData) { |
||||
results.push(turnDataIntoMedia(item)); |
||||
} |
||||
|
||||
return results; |
||||
}, |
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> { |
||||
let url = ""; |
||||
|
||||
if (media.mediaType === MWMediaType.MOVIE) { |
||||
url = `${conf().CORS_PROXY_URL}https://theflix.to/movie/${ |
||||
media.mediaId |
||||
}?movieInfo=${media.mediaId}`;
|
||||
} else if (media.mediaType === MWMediaType.SERIES) { |
||||
url = `${conf().CORS_PROXY_URL}https://theflix.to/tv-show/${ |
||||
media.mediaId |
||||
}/season-${media.seasonId}/episode-${media.episodeId}`;
|
||||
} |
||||
|
||||
const res = await fetch(url).then((d) => d.text()); |
||||
|
||||
const prop: HTMLElement | undefined = Array.from( |
||||
new DOMParser() |
||||
.parseFromString(res, "text/html") |
||||
.querySelectorAll("script") |
||||
).find((e) => e.textContent?.includes("theflixvd.b-cdn")); |
||||
|
||||
if (!prop || !prop.textContent) { |
||||
throw new Error("Could not find stream"); |
||||
} |
||||
|
||||
const data = JSON.parse(prop.textContent); |
||||
|
||||
return { url: data.props.pageProps.videoUrl, type: "mp4", captions: [] }; |
||||
}, |
||||
|
||||
async getSeasonDataFromMedia( |
||||
media: MWPortableMedia |
||||
): Promise<MWMediaSeasons> { |
||||
const url = `${conf().CORS_PROXY_URL}https://theflix.to/tv-show/${ |
||||
media.mediaId |
||||
}/season-${media.seasonId}/episode-${media.episodeId}`;
|
||||
const res = await fetch(url).then((d) => d.text()); |
||||
|
||||
const node: Element = Array.from( |
||||
new DOMParser() |
||||
.parseFromString(res, "text/html") |
||||
.querySelectorAll(`script[id="__NEXT_DATA__"]`) |
||||
)[0]; |
||||
|
||||
let data = JSON.parse(node.innerHTML).props.pageProps.selectedTv.seasons; |
||||
|
||||
data = data.filter((season: any) => season.releaseDate != null); |
||||
data = data.map((season: any) => { |
||||
const episodes = season.episodes.filter( |
||||
(episode: any) => episode.releaseDate != null |
||||
); |
||||
return { ...season, episodes }; |
||||
}); |
||||
|
||||
return { |
||||
seasons: data.map((d: any) => ({ |
||||
sort: d.seasonNumber === 0 ? 999 : d.seasonNumber, |
||||
id: d.seasonNumber.toString(), |
||||
type: d.seasonNumber === 0 ? "special" : "season", |
||||
title: d.name, |
||||
episodes: d.episodes.map((e: any) => ({ |
||||
title: e.name, |
||||
sort: e.episodeNumber, |
||||
id: e.episodeNumber.toString(), |
||||
episodeNumber: e.episodeNumber, |
||||
})), |
||||
})), |
||||
}; |
||||
}, |
||||
}; |
||||
@ -1,36 +0,0 @@
@@ -1,36 +0,0 @@
|
||||
import { conf } from "@/config"; |
||||
import { MWMediaType, MWPortableMedia } from "@/providers/types"; |
||||
|
||||
const getTheFlixUrl = (media: MWPortableMedia, params?: URLSearchParams) => { |
||||
if (media.mediaType === MWMediaType.MOVIE) { |
||||
return `https://theflix.to/movie/${media.mediaId}?${params}`; |
||||
} |
||||
if (media.mediaType === MWMediaType.SERIES) { |
||||
return `https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`; |
||||
} |
||||
|
||||
return ""; |
||||
}; |
||||
|
||||
export async function getDataFromPortableSearch( |
||||
media: MWPortableMedia |
||||
): Promise<any> { |
||||
const params = new URLSearchParams(); |
||||
params.append("movieInfo", media.mediaId); |
||||
|
||||
const res = await fetch( |
||||
conf().CORS_PROXY_URL + getTheFlixUrl(media, params) |
||||
).then((d) => d.text()); |
||||
|
||||
const node: Element = Array.from( |
||||
new DOMParser() |
||||
.parseFromString(res, "text/html") |
||||
.querySelectorAll(`script[id="__NEXT_DATA__"]`) |
||||
)[0]; |
||||
|
||||
if (media.mediaType === MWMediaType.MOVIE) { |
||||
return JSON.parse(node.innerHTML).props.pageProps.movie; |
||||
} |
||||
// must be series here
|
||||
return JSON.parse(node.innerHTML).props.pageProps.selectedTv; |
||||
} |
||||
@ -1,48 +0,0 @@
@@ -1,48 +0,0 @@
|
||||
import { conf } from "@/config"; |
||||
import { MWMediaType, MWProviderMediaResult, MWQuery } from "@/providers"; |
||||
|
||||
const getTheFlixUrl = (type: "tv-shows" | "movies", params: URLSearchParams) => |
||||
`https://theflix.to/${type}/trending?${params}`; |
||||
|
||||
export function searchTheFlix(query: MWQuery): Promise<string> { |
||||
const params = new URLSearchParams(); |
||||
params.append("search", query.searchQuery); |
||||
return fetch( |
||||
conf().CORS_PROXY_URL + |
||||
getTheFlixUrl( |
||||
query.type === MWMediaType.MOVIE ? "movies" : "tv-shows", |
||||
params |
||||
) |
||||
).then((d) => d.text()); |
||||
} |
||||
|
||||
export function getDataFromSearch(page: string, limit = 10): any[] { |
||||
const node: Element = Array.from( |
||||
new DOMParser() |
||||
.parseFromString(page, "text/html") |
||||
.querySelectorAll(`script[id="__NEXT_DATA__"]`) |
||||
)[0]; |
||||
const data = JSON.parse(node.innerHTML); |
||||
return data.props.pageProps.mainList.docs |
||||
.filter((d: any) => d.available) |
||||
.slice(0, limit); |
||||
} |
||||
|
||||
export function turnDataIntoMedia(data: any): MWProviderMediaResult { |
||||
return { |
||||
mediaId: `${data.id}-${data.name |
||||
.replace(/[^a-z0-9]+|\s+/gim, " ") |
||||
.trim() |
||||
.replace(/\s+/g, "-") |
||||
.toLowerCase()}`,
|
||||
title: data.name, |
||||
year: new Date(data.releaseDate).getFullYear().toString(), |
||||
seasonCount: data.numberOfSeasons, |
||||
episodeId: data.lastReleasedEpisode |
||||
? data.lastReleasedEpisode.episodeNumber.toString() |
||||
: null, |
||||
seasonId: data.lastReleasedEpisode |
||||
? data.lastReleasedEpisode.seasonNumber.toString() |
||||
: null, |
||||
}; |
||||
} |
||||
@ -1,142 +0,0 @@
@@ -1,142 +0,0 @@
|
||||
import { |
||||
MWMediaProvider, |
||||
MWMediaType, |
||||
MWPortableMedia, |
||||
MWMediaStream, |
||||
MWQuery, |
||||
MWProviderMediaResult, |
||||
MWMediaCaption, |
||||
} from "@/providers/types"; |
||||
|
||||
import { conf } from "@/config"; |
||||
|
||||
export const xemovieScraper: MWMediaProvider = { |
||||
id: "xemovie", |
||||
enabled: false, |
||||
type: [MWMediaType.MOVIE], |
||||
displayName: "xemovie", |
||||
|
||||
async getMediaFromPortable( |
||||
media: MWPortableMedia |
||||
): Promise<MWProviderMediaResult> { |
||||
const res = await fetch( |
||||
`${conf().CORS_PROXY_URL}https://xemovie.co/movies/${media.mediaId}/watch` |
||||
).then((d) => d.text()); |
||||
|
||||
const DOM = new DOMParser().parseFromString(res, "text/html"); |
||||
|
||||
const title = |
||||
DOM.querySelector(".text-primary.text-lg.font-extrabold")?.textContent || |
||||
""; |
||||
const year = |
||||
DOM.querySelector("div.justify-between:nth-child(3) > div:nth-child(2)") |
||||
?.textContent || ""; |
||||
|
||||
return { |
||||
...media, |
||||
title, |
||||
year, |
||||
} as MWProviderMediaResult; |
||||
}, |
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> { |
||||
const term = query.searchQuery.toLowerCase(); |
||||
|
||||
const searchUrl = `${ |
||||
conf().CORS_PROXY_URL |
||||
}https://xemovie.co/search?q=${encodeURIComponent(term)}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text()); |
||||
|
||||
const parser = new DOMParser(); |
||||
const doc = parser.parseFromString(searchRes, "text/html"); |
||||
|
||||
const movieContainer = doc |
||||
.querySelectorAll(".py-10")[0] |
||||
.querySelector(".grid"); |
||||
if (!movieContainer) return []; |
||||
const movieNodes = Array.from(movieContainer.querySelectorAll("a")).filter( |
||||
(link) => !link.className |
||||
); |
||||
|
||||
const results: MWProviderMediaResult[] = movieNodes |
||||
.map((node) => { |
||||
const parent = node.parentElement; |
||||
if (!parent) return; |
||||
|
||||
const aElement = parent.querySelector("a"); |
||||
if (!aElement) return; |
||||
|
||||
return { |
||||
title: parent.querySelector("div > div > a > h6")?.textContent, |
||||
year: parent.querySelector("div.float-right")?.textContent, |
||||
mediaId: aElement.href.split("/").pop() || "", |
||||
}; |
||||
}) |
||||
.filter((d): d is MWProviderMediaResult => !!d); |
||||
|
||||
return results; |
||||
}, |
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> { |
||||
if (media.mediaType !== MWMediaType.MOVIE) |
||||
throw new Error("Incorrect type"); |
||||
|
||||
const url = `${conf().CORS_PROXY_URL}https://xemovie.co/movies/${ |
||||
media.mediaId |
||||
}/watch`;
|
||||
|
||||
let streamUrl = ""; |
||||
const subtitles: MWMediaCaption[] = []; |
||||
|
||||
const res = await fetch(url).then((d) => d.text()); |
||||
const scripts = Array.from( |
||||
new DOMParser() |
||||
.parseFromString(res, "text/html") |
||||
.querySelectorAll("script") |
||||
); |
||||
|
||||
for (const script of scripts) { |
||||
if (!script.textContent) continue; |
||||
|
||||
if (script.textContent.match(/https:\/\/[a-z][0-9]\.xemovie\.com/)) { |
||||
const data = JSON.parse( |
||||
JSON.stringify( |
||||
eval( |
||||
`(${ |
||||
script.textContent.replace("const data = ", "").split("};")[0] |
||||
}})` |
||||
) |
||||
) |
||||
); |
||||
streamUrl = data.playlist[0].file; |
||||
|
||||
for (const [ |
||||
index, |
||||
subtitleTrack, |
||||
] of data.playlist[0].tracks.entries()) { |
||||
const subtitleBlob = URL.createObjectURL( |
||||
await fetch(`${conf().CORS_PROXY_URL}${subtitleTrack.file}`).then( |
||||
(captionRes) => captionRes.blob() |
||||
) |
||||
); // do this so no need for CORS errors
|
||||
|
||||
subtitles.push({ |
||||
id: index, |
||||
url: subtitleBlob, |
||||
label: subtitleTrack.label, |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
const streamType = streamUrl.split(".").at(-1); |
||||
if (streamType !== "mp4" && streamType !== "m3u8") |
||||
throw new Error("Unsupported stream type"); |
||||
|
||||
return { |
||||
url: streamUrl, |
||||
type: streamType, |
||||
captions: subtitles, |
||||
} as MWMediaStream; |
||||
}, |
||||
}; |
||||
@ -1,11 +0,0 @@
@@ -1,11 +0,0 @@
|
||||
import { SimpleCache } from "@/utils/cache"; |
||||
import { MWPortableMedia, MWMedia } from "@/providers"; |
||||
|
||||
// cache
|
||||
const contentCache = new SimpleCache<MWPortableMedia, MWMedia>(); |
||||
contentCache.setCompare( |
||||
(a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId |
||||
); |
||||
contentCache.initialize(); |
||||
|
||||
export default contentCache; |
||||
@ -1,65 +0,0 @@
@@ -1,65 +0,0 @@
|
||||
import { MWMediaType, MWMediaProviderMetadata } from "@/providers"; |
||||
import { MWMedia, MWMediaEpisode, MWMediaSeason } from "@/providers/types"; |
||||
import { mediaProviders, mediaProvidersUnchecked } from "./providers"; |
||||
|
||||
/* |
||||
** Fetch all enabled providers for a specific type |
||||
*/ |
||||
export function GetProvidersForType(type: MWMediaType) { |
||||
return mediaProviders.filter((v) => v.type.includes(type)); |
||||
} |
||||
|
||||
/* |
||||
** Get a provider by a id |
||||
*/ |
||||
export function getProviderFromId(id: string) { |
||||
return mediaProviders.find((v) => v.id === id); |
||||
} |
||||
|
||||
/* |
||||
** Get a provider metadata |
||||
*/ |
||||
export function getProviderMetadata(id: string): MWMediaProviderMetadata { |
||||
const provider = mediaProvidersUnchecked.find((v) => v.id === id); |
||||
|
||||
if (!provider) { |
||||
return { |
||||
exists: false, |
||||
type: [], |
||||
enabled: false, |
||||
id, |
||||
}; |
||||
} |
||||
|
||||
return { |
||||
exists: true, |
||||
type: provider.type, |
||||
enabled: provider.enabled, |
||||
id, |
||||
provider, |
||||
}; |
||||
} |
||||
|
||||
/* |
||||
** get episode and season from media |
||||
*/ |
||||
export function getEpisodeFromMedia( |
||||
media: MWMedia |
||||
): { season: MWMediaSeason; episode: MWMediaEpisode } | null { |
||||
if ( |
||||
media.seasonId === undefined || |
||||
media.episodeId === undefined || |
||||
media.seriesData === undefined |
||||
) { |
||||
return null; |
||||
} |
||||
|
||||
const season = media.seriesData.seasons.find((v) => v.id === media.seasonId); |
||||
if (!season) return null; |
||||
const episode = season?.episodes.find((v) => v.id === media.episodeId); |
||||
if (!episode) return null; |
||||
return { |
||||
season, |
||||
episode, |
||||
}; |
||||
} |
||||
@ -1,19 +0,0 @@
@@ -1,19 +0,0 @@
|
||||
import { theFlixScraper } from "@/providers/list/theflix"; |
||||
import { gDrivePlayerScraper } from "@/providers/list/gdriveplayer"; |
||||
import { MWWrappedMediaProvider, WrapProvider } from "@/providers/wrapper"; |
||||
import { gomostreamScraper } from "@/providers/list/gomostream"; |
||||
import { xemovieScraper } from "@/providers/list/xemovie"; |
||||
import { flixhqProvider } from "@/providers/list/flixhq"; |
||||
import { superStreamScraper } from "@/providers/list/superstream"; |
||||
|
||||
export const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [ |
||||
WrapProvider(superStreamScraper), |
||||
WrapProvider(theFlixScraper), |
||||
WrapProvider(gDrivePlayerScraper), |
||||
WrapProvider(gomostreamScraper), |
||||
WrapProvider(xemovieScraper), |
||||
WrapProvider(flixhqProvider), |
||||
]; |
||||
|
||||
export const mediaProviders: MWWrappedMediaProvider[] = |
||||
mediaProvidersUnchecked.filter((v) => v.enabled); |
||||
@ -1,105 +0,0 @@
@@ -1,105 +0,0 @@
|
||||
import Fuse from "fuse.js"; |
||||
import { |
||||
MWMassProviderOutput, |
||||
MWMedia, |
||||
MWQuery, |
||||
convertMediaToPortable, |
||||
} from "@/providers"; |
||||
import { SimpleCache } from "@/utils/cache"; |
||||
import { GetProvidersForType } from "./helpers"; |
||||
import contentCache from "./contentCache"; |
||||
|
||||
// cache
|
||||
const resultCache = new SimpleCache<MWQuery, MWMassProviderOutput>(); |
||||
resultCache.setCompare( |
||||
(a, b) => a.searchQuery === b.searchQuery && a.type === b.type |
||||
); |
||||
resultCache.initialize(); |
||||
|
||||
/* |
||||
** actually call all providers with the search query |
||||
*/ |
||||
async function callProviders(query: MWQuery): Promise<MWMassProviderOutput> { |
||||
const allQueries = GetProvidersForType(query.type).map< |
||||
Promise<{ media: MWMedia[]; success: boolean; id: string }> |
||||
>(async (provider) => { |
||||
try { |
||||
return { |
||||
media: await provider.searchForMedia(query), |
||||
success: true, |
||||
id: provider.id, |
||||
}; |
||||
} catch (err) { |
||||
console.error(`Failed running provider ${provider.id}`, err, query); |
||||
return { |
||||
media: [], |
||||
success: false, |
||||
id: provider.id, |
||||
}; |
||||
} |
||||
}); |
||||
const allResults = await Promise.all(allQueries); |
||||
const providerResults = allResults.map((provider) => ({ |
||||
success: provider.success, |
||||
id: provider.id, |
||||
})); |
||||
const output: MWMassProviderOutput = { |
||||
results: allResults.flatMap((results) => results.media), |
||||
providers: providerResults, |
||||
stats: { |
||||
total: providerResults.length, |
||||
failed: providerResults.filter((v) => !v.success).length, |
||||
succeeded: providerResults.filter((v) => v.success).length, |
||||
}, |
||||
}; |
||||
|
||||
// save in cache if all successfull
|
||||
if (output.stats.failed === 0) { |
||||
resultCache.set(query, output, 60 * 60); // cache for an hour
|
||||
} |
||||
|
||||
output.results.forEach((result: MWMedia) => { |
||||
contentCache.set(convertMediaToPortable(result), result, 60 * 60); |
||||
}); |
||||
|
||||
return output; |
||||
} |
||||
|
||||
/* |
||||
** sort results based on query |
||||
*/ |
||||
function sortResults( |
||||
query: MWQuery, |
||||
providerResults: MWMassProviderOutput |
||||
): MWMassProviderOutput { |
||||
const results: MWMassProviderOutput = { ...providerResults }; |
||||
const fuse = new Fuse(results.results, { |
||||
threshold: 0.3, |
||||
keys: ["title"], |
||||
fieldNormWeight: 0.5, |
||||
}); |
||||
results.results = fuse.search(query.searchQuery).map((v) => v.item); |
||||
return results; |
||||
} |
||||
|
||||
/* |
||||
** Call search on all providers that matches query type |
||||
*/ |
||||
export async function SearchProviders( |
||||
inputQuery: MWQuery |
||||
): Promise<MWMassProviderOutput> { |
||||
// input normalisation
|
||||
const query = { ...inputQuery }; |
||||
query.searchQuery = query.searchQuery.toLowerCase().trim(); |
||||
|
||||
// consult cache first
|
||||
let output = resultCache.get(query); |
||||
if (!output) output = await callProviders(query); |
||||
|
||||
// sort results
|
||||
output = sortResults(query, output); |
||||
|
||||
if (output.stats.total === output.stats.failed) |
||||
throw new Error("All Scrapers failed"); |
||||
return output; |
||||
} |
||||
@ -1,50 +0,0 @@
@@ -1,50 +0,0 @@
|
||||
import { SimpleCache } from "@/utils/cache"; |
||||
import { MWPortableMedia } from "@/providers"; |
||||
import { |
||||
MWMediaSeasons, |
||||
MWMediaType, |
||||
MWMediaProviderSeries, |
||||
} from "@/providers/types"; |
||||
import { getProviderFromId } from "./helpers"; |
||||
|
||||
// cache
|
||||
const seasonCache = new SimpleCache<MWPortableMedia, MWMediaSeasons>(); |
||||
seasonCache.setCompare( |
||||
(a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId |
||||
); |
||||
seasonCache.initialize(); |
||||
|
||||
/* |
||||
** get season data from a (portable) media object, seasons and episodes will be sorted |
||||
*/ |
||||
export async function getSeasonDataFromMedia( |
||||
media: MWPortableMedia |
||||
): Promise<MWMediaSeasons> { |
||||
const provider = getProviderFromId(media.providerId) as MWMediaProviderSeries; |
||||
if (!provider) { |
||||
return { |
||||
seasons: [], |
||||
}; |
||||
} |
||||
|
||||
if ( |
||||
!provider.type.includes(MWMediaType.SERIES) && |
||||
!provider.type.includes(MWMediaType.ANIME) |
||||
) { |
||||
return { |
||||
seasons: [], |
||||
}; |
||||
} |
||||
|
||||
if (seasonCache.has(media)) { |
||||
return seasonCache.get(media) as MWMediaSeasons; |
||||
} |
||||
|
||||
const seasonData = await provider.getSeasonDataFromMedia(media); |
||||
seasonData.seasons.sort((a, b) => a.sort - b.sort); |
||||
seasonData.seasons.forEach((s) => s.episodes.sort((a, b) => a.sort - b.sort)); |
||||
|
||||
// cache it
|
||||
seasonCache.set(media, seasonData, 60 * 60); // cache it for an hour
|
||||
return seasonData; |
||||
} |
||||
@ -1,97 +0,0 @@
@@ -1,97 +0,0 @@
|
||||
export enum MWMediaType { |
||||
MOVIE = "movie", |
||||
SERIES = "series", |
||||
ANIME = "anime", |
||||
} |
||||
|
||||
export interface MWPortableMedia { |
||||
mediaId: string; |
||||
mediaType: MWMediaType; |
||||
providerId: string; |
||||
seasonId?: string; |
||||
episodeId?: string; |
||||
} |
||||
|
||||
export type MWMediaStreamType = "m3u8" | "mp4"; |
||||
export interface MWMediaCaption { |
||||
id: string; |
||||
url: string; |
||||
label: string; |
||||
} |
||||
export interface MWMediaStream { |
||||
url: string; |
||||
type: MWMediaStreamType; |
||||
captions: MWMediaCaption[]; |
||||
} |
||||
|
||||
export interface MWMediaMeta extends MWPortableMedia { |
||||
title: string; |
||||
year: string; |
||||
seasonCount?: number; |
||||
} |
||||
|
||||
export interface MWMediaEpisode { |
||||
sort: number; |
||||
id: string; |
||||
title: string; |
||||
} |
||||
export interface MWMediaSeason { |
||||
sort: number; |
||||
id: string; |
||||
title?: string; |
||||
type: "season" | "special"; |
||||
episodes: MWMediaEpisode[]; |
||||
} |
||||
export interface MWMediaSeasons { |
||||
seasons: MWMediaSeason[]; |
||||
} |
||||
|
||||
export interface MWMedia extends MWMediaMeta { |
||||
seriesData?: MWMediaSeasons; |
||||
} |
||||
|
||||
export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">; |
||||
|
||||
export interface MWQuery { |
||||
searchQuery: string; |
||||
type: MWMediaType; |
||||
} |
||||
|
||||
export interface MWMediaProviderBase { |
||||
id: string; // id of provider, must be unique
|
||||
enabled: boolean; |
||||
type: MWMediaType[]; |
||||
displayName: string; |
||||
|
||||
getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult>; |
||||
searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]>; |
||||
getStream(media: MWPortableMedia): Promise<MWMediaStream>; |
||||
getSeasonDataFromMedia?: (media: MWPortableMedia) => Promise<MWMediaSeasons>; |
||||
} |
||||
|
||||
export type MWMediaProviderSeries = MWMediaProviderBase & { |
||||
getSeasonDataFromMedia: (media: MWPortableMedia) => Promise<MWMediaSeasons>; |
||||
}; |
||||
|
||||
export type MWMediaProvider = MWMediaProviderBase; |
||||
|
||||
export interface MWMediaProviderMetadata { |
||||
exists: boolean; |
||||
id?: string; |
||||
enabled: boolean; |
||||
type: MWMediaType[]; |
||||
provider?: MWMediaProvider; |
||||
} |
||||
|
||||
export interface MWMassProviderOutput { |
||||
providers: { |
||||
id: string; |
||||
success: boolean; |
||||
}[]; |
||||
results: MWMedia[]; |
||||
stats: { |
||||
total: number; |
||||
failed: number; |
||||
succeeded: number; |
||||
}; |
||||
} |
||||
@ -1,48 +0,0 @@
@@ -1,48 +0,0 @@
|
||||
import contentCache from "./methods/contentCache"; |
||||
import { |
||||
MWMedia, |
||||
MWMediaProvider, |
||||
MWMediaStream, |
||||
MWPortableMedia, |
||||
MWQuery, |
||||
} from "./types"; |
||||
|
||||
export interface MWWrappedMediaProvider extends MWMediaProvider { |
||||
getMediaFromPortable(media: MWPortableMedia): Promise<MWMedia>; |
||||
searchForMedia(query: MWQuery): Promise<MWMedia[]>; |
||||
getStream(media: MWPortableMedia): Promise<MWMediaStream>; |
||||
} |
||||
|
||||
export function WrapProvider( |
||||
provider: MWMediaProvider |
||||
): MWWrappedMediaProvider { |
||||
return { |
||||
...provider, |
||||
|
||||
async getMediaFromPortable(media: MWPortableMedia): Promise<MWMedia> { |
||||
// consult cache first
|
||||
const output = contentCache.get(media); |
||||
if (output) { |
||||
output.seasonId = media.seasonId; |
||||
output.episodeId = media.episodeId; |
||||
return output; |
||||
} |
||||
|
||||
const mediaObject = { |
||||
...(await provider.getMediaFromPortable(media)), |
||||
providerId: provider.id, |
||||
mediaType: media.mediaType, |
||||
}; |
||||
contentCache.set(media, mediaObject, 60 * 60); |
||||
return mediaObject; |
||||
}, |
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWMedia[]> { |
||||
return (await provider.searchForMedia(query)).map<MWMedia>((m) => ({ |
||||
...m, |
||||
providerId: provider.id, |
||||
mediaType: query.type, |
||||
})); |
||||
}, |
||||
}; |
||||
} |
||||
@ -1,22 +1,28 @@
@@ -1,22 +1,28 @@
|
||||
import { Redirect, Route, Switch } from "react-router-dom"; |
||||
import { MWMediaType } from "@/providers"; |
||||
import { BookmarkContextProvider } from "@/state/bookmark"; |
||||
import { WatchedContextProvider } from "@/state/watched"; |
||||
|
||||
import { NotFoundPage } from "@/views/notfound/NotFoundView"; |
||||
import "./index.css"; |
||||
import { MediaView } from "./views/MediaView"; |
||||
import { SearchView } from "./views/SearchView"; |
||||
import { MediaView } from "@/views/media/MediaView"; |
||||
import { SearchView } from "@/views/search/SearchView"; |
||||
import { MWMediaType } from "@/backend/metadata/types"; |
||||
import { V2MigrationView } from "@/views/other/v2Migration"; |
||||
|
||||
function App() { |
||||
return ( |
||||
<WatchedContextProvider> |
||||
<BookmarkContextProvider> |
||||
<Switch> |
||||
<Route exact path="/v2-migration" component={V2MigrationView} /> |
||||
<Route exact path="/"> |
||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} /> |
||||
</Route> |
||||
<Route exact path="/media/movie/:media" component={MediaView} /> |
||||
<Route exact path="/media/series/:media" component={MediaView} /> |
||||
<Route exact path="/media/:media" component={MediaView} /> |
||||
<Route |
||||
exact |
||||
path="/media/:media/:season/:episode" |
||||
component={MediaView} |
||||
/> |
||||
<Route exact path="/search/:type/:query?" component={SearchView} /> |
||||
<Route path="*" component={NotFoundPage} /> |
||||
</Switch> |
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
const CHROMECAST_SENDER_SDK = |
||||
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; |
||||
|
||||
const callbacks: ((available: boolean) => void)[] = []; |
||||
let _available: boolean | null = null; |
||||
|
||||
function init(available: boolean) { |
||||
_available = available; |
||||
callbacks.forEach((cb) => cb(available)); |
||||
} |
||||
|
||||
export function isChromecastAvailable(cb: (available: boolean) => void) { |
||||
if (_available !== null) return cb(_available); |
||||
callbacks.push(cb); |
||||
} |
||||
|
||||
export function initializeChromecast() { |
||||
window.__onGCastApiAvailable = (isAvailable) => { |
||||
init(isAvailable); |
||||
}; |
||||
|
||||
// add script if doesnt exist yet
|
||||
const exists = !!document.getElementById("chromecast-script"); |
||||
if (!exists) { |
||||
const script = document.createElement("script"); |
||||
script.setAttribute("src", CHROMECAST_SENDER_SDK); |
||||
script.setAttribute("id", "chromecast-script"); |
||||
document.body.appendChild(script); |
||||
} |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue