196 changed files with 11168 additions and 5152 deletions
@ -0,0 +1,7 @@ |
|||||||
|
root = true |
||||||
|
|
||||||
|
[*] |
||||||
|
end_of_line = lf |
||||||
|
insert_final_newline = true |
||||||
|
indent_size = 2 |
||||||
|
indent_style = space |
||||||
@ -1,7 +1,5 @@ |
|||||||
{ |
{ |
||||||
"files.eol": "\n", |
"editor.formatOnSave": true, |
||||||
"editor.detectIndentation": false, |
"editor.defaultFormatter": "dbaeumer.vscode-eslint", |
||||||
"editor.tabSize": 2, |
"eslint.format.enable": true |
||||||
"editor.formatOnSave": true, |
} |
||||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint", |
|
||||||
} |
|
||||||
|
|||||||
@ -0,0 +1,4 @@ |
|||||||
|
module.exports = { |
||||||
|
trailingComma: "all", |
||||||
|
singleQuote: true |
||||||
|
}; |
||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
import { useTranslation } from "react-i18next"; |
import { useTranslation } from "react-i18next"; |
||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
|
||||||
export function BrandPill(props: { clickable?: boolean }) { |
export function BrandPill(props: { clickable?: boolean }) { |
||||||
const { t } = useTranslation(); |
const { t } = useTranslation(); |
||||||
|
|
||||||
return ( |
return ( |
||||||
<div |
<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 |
className={`flex items-center space-x-2 rounded-full bg-bink-300 bg-opacity-50 px-4 py-2 text-bink-600 ${ |
||||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-200 hover:text-bink-700 active:scale-95" |
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} /> |
<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> |
</div> |
||||||
); |
); |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
import { ReactNode } from "react"; |
import { ReactNode } from "react"; |
||||||
|
|
||||||
export interface PaperProps { |
export interface PaperProps { |
||||||
children?: ReactNode, |
children?: ReactNode; |
||||||
className?: string, |
className?: string; |
||||||
} |
} |
||||||
|
|
||||||
export function Paper(props: PaperProps) { |
export function Paper(props: PaperProps) { |
||||||
return ( |
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} |
{props.children} |
||||||
</div> |
</div> |
||||||
) |
); |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
import "./Spinner.css"; |
||||||
|
|
||||||
|
interface SpinnerProps { |
||||||
|
className?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function Spinner(props: SpinnerProps) { |
||||||
|
return <div className={["spinner", props.className ?? ""].join(" ")} />; |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
import { Link } from "react-router-dom"; |
import { Link } from "react-router-dom"; |
||||||
import { |
import { useTranslation } from "react-i18next"; |
||||||
convertMediaToPortable, |
|
||||||
getProviderFromId, |
|
||||||
MWMediaMeta, |
|
||||||
MWMediaType, |
|
||||||
} from "@/providers"; |
|
||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
import { serializePortableMedia } from "@/hooks/usePortableMedia"; |
|
||||||
import { DotList } from "@/components/text/DotList"; |
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 { |
export interface MediaCardProps { |
||||||
media: MWMediaMeta; |
media: MWMediaMeta; |
||||||
watchedPercentage: number; |
|
||||||
linkable?: boolean; |
linkable?: boolean; |
||||||
series?: boolean; |
series?: { |
||||||
|
episode: number; |
||||||
|
season: number; |
||||||
|
episodeId: string; |
||||||
|
seasonId: string; |
||||||
|
}; |
||||||
|
percentage?: number; |
||||||
|
closable?: boolean; |
||||||
|
onClose?: () => void; |
||||||
} |
} |
||||||
|
|
||||||
function MediaCardContent({ |
function MediaCardContent({ |
||||||
media, |
media, |
||||||
linkable, |
linkable, |
||||||
watchedPercentage, |
|
||||||
series, |
series, |
||||||
|
percentage, |
||||||
|
closable, |
||||||
|
onClose, |
||||||
}: MediaCardProps) { |
}: MediaCardProps) { |
||||||
const provider = getProviderFromId(media.providerId); |
const { t } = useTranslation(); |
||||||
|
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; |
||||||
|
|
||||||
if (!provider) { |
const canLink = linkable && !closable; |
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
return ( |
||||||
<article |
<div |
||||||
className={`group relative mb-4 flex overflow-hidden rounded bg-denim-300 py-4 px-5 ${ |
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${ |
||||||
linkable ? "hover:bg-denim-400" : "" |
canLink ? "hover:bg-opacity-100" : "" |
||||||
}`}
|
}`}
|
||||||
> |
> |
||||||
{/* progress background */} |
<article |
||||||
{watchedPercentage > 0 ? ( |
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${ |
||||||
<div className="absolute top-0 left-0 right-0 bottom-0"> |
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 |
<div |
||||||
className="relative h-full bg-bink-300 bg-opacity-30" |
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${ |
||||||
style={{ |
closable ? "opacity-100" : "pointer-events-none opacity-0" |
||||||
width: `${watchedPercentage}%`, |
}`}
|
||||||
}} |
|
||||||
> |
> |
||||||
<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> |
||||||
</div> |
</div> |
||||||
) : null} |
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3"> |
||||||
|
<span>{media.title}</span> |
||||||
<div className="relative flex flex-1"> |
</h1> |
||||||
{/* card content */} |
<DotList |
||||||
<div className="flex-1"> |
className="text-xs" |
||||||
<h1 className="mb-1 font-bold text-white"> |
content={[t(`media.${media.type}`), media.year]} |
||||||
{media.title} |
/> |
||||||
{series && media.seasonId && media.episodeId ? ( |
</article> |
||||||
<span className="ml-2 text-xs text-denim-700"> |
</div> |
||||||
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> |
|
||||||
); |
); |
||||||
} |
} |
||||||
|
|
||||||
export function MediaCard(props: MediaCardProps) { |
export function MediaCard(props: MediaCardProps) { |
||||||
let link = "movie"; |
|
||||||
if (props.media.mediaType === MWMediaType.SERIES) link = "series"; |
|
||||||
|
|
||||||
const content = <MediaCardContent {...props} />; |
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>; |
if (!props.linkable) return <span>{content}</span>; |
||||||
return ( |
return <Link to={link}>{content}</Link>; |
||||||
<Link |
|
||||||
to={`/media/${link}/${serializePortableMedia( |
|
||||||
convertMediaToPortable(props.media) |
|
||||||
)}`}
|
|
||||||
> |
|
||||||
{content} |
|
||||||
</Link> |
|
||||||
); |
|
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
import { MWMediaMeta } from "@/providers"; |
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched"; |
import { useWatchedContext } from "@/state/watched"; |
||||||
|
import { useMemo } from "react"; |
||||||
import { MediaCard } from "./MediaCard"; |
import { MediaCard } from "./MediaCard"; |
||||||
|
|
||||||
export interface WatchedMediaCardProps { |
export interface WatchedMediaCardProps { |
||||||
media: MWMediaMeta; |
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) { |
export function WatchedMediaCard(props: WatchedMediaCardProps) { |
||||||
const { watched } = useWatchedContext(); |
const { watched } = useWatchedContext(); |
||||||
const foundWatched = getWatchedFromPortable(watched.items, props.media); |
const watchedMedia = useMemo(() => { |
||||||
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0; |
return watched.items |
||||||
|
.sort((a, b) => b.watchedAt - a.watchedAt) |
||||||
|
.find((v) => v.item.meta.id === props.media.id); |
||||||
|
}, [watched, props.media]); |
||||||
|
|
||||||
return ( |
return ( |
||||||
<MediaCard |
<MediaCard |
||||||
watchedPercentage={watchedPercentage} |
|
||||||
media={props.media} |
media={props.media} |
||||||
series={props.series && props.media.episodeId !== undefined} |
series={formatSeries(watchedMedia?.item?.series)} |
||||||
linkable |
linkable |
||||||
|
percentage={watchedMedia?.percentage} |
||||||
|
onClose={props.onClose} |
||||||
|
closable={props.closable} |
||||||
/> |
/> |
||||||
); |
); |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
export interface TitleProps { |
export interface TitleProps { |
||||||
children?: React.ReactNode; |
children?: React.ReactNode; |
||||||
|
className?: string; |
||||||
} |
} |
||||||
|
|
||||||
export function Title(props: TitleProps) { |
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 @@ |
|||||||
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 @@ |
|||||||
|
/// <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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
@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 @@ |
|||||||
# 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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
// 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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
import { Redirect, Route, Switch } from "react-router-dom"; |
import { Redirect, Route, Switch } from "react-router-dom"; |
||||||
import { MWMediaType } from "@/providers"; |
|
||||||
import { BookmarkContextProvider } from "@/state/bookmark"; |
import { BookmarkContextProvider } from "@/state/bookmark"; |
||||||
import { WatchedContextProvider } from "@/state/watched"; |
import { WatchedContextProvider } from "@/state/watched"; |
||||||
|
|
||||||
import { NotFoundPage } from "@/views/notfound/NotFoundView"; |
import { NotFoundPage } from "@/views/notfound/NotFoundView"; |
||||||
import "./index.css"; |
import { MediaView } from "@/views/media/MediaView"; |
||||||
import { MediaView } from "./views/MediaView"; |
import { SearchView } from "@/views/search/SearchView"; |
||||||
import { SearchView } from "./views/SearchView"; |
import { MWMediaType } from "@/backend/metadata/types"; |
||||||
|
import { V2MigrationView } from "@/views/other/v2Migration"; |
||||||
|
|
||||||
function App() { |
function App() { |
||||||
return ( |
return ( |
||||||
<WatchedContextProvider> |
<WatchedContextProvider> |
||||||
<BookmarkContextProvider> |
<BookmarkContextProvider> |
||||||
<Switch> |
<Switch> |
||||||
|
<Route exact path="/v2-migration" component={V2MigrationView} /> |
||||||
<Route exact path="/"> |
<Route exact path="/"> |
||||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} /> |
<Redirect to={`/search/${MWMediaType.MOVIE}`} /> |
||||||
</Route> |
</Route> |
||||||
<Route exact path="/media/movie/:media" component={MediaView} /> |
<Route exact path="/media/:media" component={MediaView} /> |
||||||
<Route exact path="/media/series/:media" component={MediaView} /> |
<Route |
||||||
|
exact |
||||||
|
path="/media/:media/:season/:episode" |
||||||
|
component={MediaView} |
||||||
|
/> |
||||||
<Route exact path="/search/:type/:query?" component={SearchView} /> |
<Route exact path="/search/:type/:query?" component={SearchView} /> |
||||||
<Route path="*" component={NotFoundPage} /> |
<Route path="*" component={NotFoundPage} /> |
||||||
</Switch> |
</Switch> |
||||||
@ -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