Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-02-19 18:03:54 +01:00
parent c90d59ef93
commit b3db58012f
34 changed files with 489 additions and 377 deletions

View File

@ -50,6 +50,7 @@ module.exports = {
"no-await-in-loop": "off",
"no-nested-ternary": "off",
"prefer-destructuring": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"react/jsx-filename-extension": [
"error",
{ extensions: [".js", ".tsx", ".jsx"] }

View File

@ -1,20 +1,19 @@
import { MWEmbedType } from "@/backend/helpers/embed";
import { MWMediaType } from "../metadata/types";
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(ctx) {
// throw new Error("Oh well 2")
return {
streamUrl: '',
quality: MWStreamQuality.Q1080P,
captions: [],
type: MWStreamType.MP4,
};
},
})
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,
};
},
});

View File

@ -1,60 +1,65 @@
import { MWEmbedType } from "@/backend/helpers/embed";
import { MWMediaType } from "../metadata/types";
import { registerEmbedScraper } from "@/backend/helpers/register";
import { MWStreamQuality, MWStreamType, MWStream } from "@/backend/helpers/streams";
import {
MWStreamQuality,
MWStreamType,
MWStream,
} from "@/backend/helpers/streams";
import { proxiedFetch } from "@/backend/helpers/fetch";
const HOST = 'streamm4u.club';
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 }) {
id: "streamm4u",
displayName: "streamm4u",
for: MWEmbedType.STREAMM4U,
rank: 100,
async getStream({ progress, url }) {
// const scrapingThreads = [];
// const streams = [];
const scrapingThreads = [];
let 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];
const sources = (await scrape(url)).sort((a, b) => Number(b.quality.replace("p", "")) - Number(a.quality.replace("p", "")));
let preferredSourceIndex = 0;
let preferredSource = sources[0];
if (!preferredSource) throw new Error("No source found");
if (!preferredSource) throw new Error("No source found")
progress(100);
progress(100)
return preferredSource
},
})
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;
}
return preferredSource;
},
});

View File

@ -3,7 +3,7 @@ import { MWStream } from "./streams";
export enum MWEmbedType {
M4UFREE = "m4ufree",
STREAMM4U = "streamm4u",
PLAYM4U = "playm4u"
PLAYM4U = "playm4u",
}
export type MWEmbed = {

View File

@ -25,15 +25,15 @@ type MWProviderRunContextBase = {
};
type MWProviderRunContextTypeSpecific =
| {
type: MWMediaType.MOVIE | MWMediaType.ANIME;
episode: undefined;
season: undefined;
}
type: MWMediaType.MOVIE | MWMediaType.ANIME;
episode: undefined;
season: undefined;
}
| {
type: MWMediaType.SERIES;
episode: string;
season: string;
};
type: MWMediaType.SERIES;
episode: string;
season: string;
};
export type MWProviderRunContext = MWProviderRunContextBase &
MWProviderRunContextTypeSpecific;
@ -50,7 +50,7 @@ async function findBestEmbedStream(
embedNum += 1;
if (!embed.type) continue;
const scraper = getEmbedScraperByType(embed.type);
if (!scraper) throw new Error("Type for embed not found: " + embed.type);
if (!scraper) throw new Error(`Type for embed not found: ${embed.type}`);
const eventId = [providerId, scraper.id, embedNum].join("|");

View File

@ -8,7 +8,7 @@ import "./providers/netfilm";
import "./providers/m4ufree";
// embeds
import "./embeds/streamm4u"
import "./embeds/playm4u"
import "./embeds/streamm4u";
import "./embeds/playm4u";
initializeScraperStore();

View File

@ -21,7 +21,10 @@ registerProvider({
}
);
const foundItem = searchResults.results.find((v: any) => {
return compareTitle(v.title, media.meta.title) && v.releaseDate === media.meta.year;
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;

View File

@ -1,11 +1,9 @@
import { compareTitle } from "@/utils/titleMatch";
import { MWEmbedType } from "../helpers/embed";
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWMediaType } from "../metadata/types";
import { MWEmbed } from "@/backend/helpers/embed";
const HOST = 'm4ufree.com';
const HOST = "m4ufree.com";
const URL_BASE = `https://${HOST}`;
const URL_SEARCH = `${URL_BASE}/search`;
const URL_AJAX = `${URL_BASE}/ajax`;
@ -22,7 +20,7 @@ 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")
return new DOMParser().parseFromString(html, "text/html");
}
registerProvider({
@ -32,9 +30,14 @@ registerProvider({
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, progress, 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
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[] = [];
@ -43,39 +46,49 @@ registerProvider({
responseType: "text" as any,
}
*/
let responseText = await proxiedFetch<string>(`${URL_SEARCH}/${encodeURIComponent(media.meta.title)}.html`);
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;
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);
let regexResult = REGEX_TITLE_AND_YEAR.exec(tooltipText);
if (!regexResult || !regexResult[1] || !regexResult[2]) {
return;
}
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;
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);
regexResult = REGEX_TYPE.exec(href);
if (!regexResult || !regexResult[1]) {
return;
}
if (!regexResult || !regexResult[1]) {
return;
}
let scraperDeterminedType = regexResult[1];
let scraperDeterminedType = regexResult[1];
scraperDeterminedType = scraperDeterminedType === 'tvshow' ? 'show' : 'movie'; // * Map to Trakt type
scraperDeterminedType =
scraperDeterminedType === "tvshow" ? "show" : "movie"; // * Map to Trakt type
return { type: scraperDeterminedType, title, year, href };
}).filter(item => item);
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);
const mediaInResults = searchResults.find(
(item) =>
item &&
item.title === media.meta.title &&
item.year.toString() === media.meta.year
);
if (!mediaInResults) {
// * Nothing found
@ -84,109 +97,124 @@ registerProvider({
};
}
let cookies: string | null = '';
const responseTextFromMedia = await proxiedFetch<string>(mediaInResults.href, {
onResponse(context) {
cookies = context.response.headers.get('X-Set-Cookie')
},
});
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")
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");
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);
const episodes = [...dom.querySelectorAll(".episode")]
.map((element) => {
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
if (!regexResult || !regexResult[1] || !regexResult[2]) {
return;
}
if (!regexResult || !regexResult[1] || !regexResult[2]) {
return;
}
const episode = Number(regexResult[1]);
const season = Number(regexResult[2]);
const newEpisode = Number(regexResult[1]);
const newSeason = Number(regexResult[2]);
return {
id: element.getAttribute('idepisode'),
episode: episode,
season: season
};
}).filter(item => item);
return {
id: element.getAttribute("idepisode"),
episode: newEpisode,
season: newSeason,
};
})
.filter((item) => item);
const ep = episodes.find(ep => ep && ep.episode === episode && ep.season === season);
if (!ep) return { embeds }
const ep = episodes.find(
(newEp) => newEp && newEp.episode === episode && newEp.season === season
);
if (!ep) return { embeds };
const form = `idepisode=${ep.id}&_token=${token}`;
let response = await proxiedFetch<string>(URL_AJAX_TV, {
method: 'POST',
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
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
body: form,
});
dom = toDom(response);
}
const servers = [...dom.querySelectorAll('.singlemv')].map(element => element.getAttribute('data'));
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',
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
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
body: form,
});
const dom = toDom(response);
const serverDom = toDom(response);
const link = dom.querySelector('iframe')?.src;
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
if (url.startsWith("https://streamm4u.club"))
return MWEmbedType.STREAMM4U;
if (url.startsWith("https://play.playm4u.xyz"))
return MWEmbedType.PLAYM4U;
return null;
}
};
if (!link) continue;
@ -194,15 +222,14 @@ registerProvider({
if (embedType) {
embeds.push({
url: link,
type: embedType
})
};
type: embedType,
});
}
}
console.log(embeds);
return {
embeds,
}
}
};
},
});

View File

@ -131,7 +131,8 @@ registerProvider({
const superstreamEntry = searchRes.find(
(res: any) =>
compareTitle(res.title, media.meta.title) && res.year === Number(media.meta.year)
compareTitle(res.title, media.meta.title) &&
res.year === Number(media.meta.year)
);
if (!superstreamEntry) throw new Error("No entry found on SuperStream");

View File

@ -0,0 +1,14 @@
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] flex h-full w-full items-center justify-center bg-[rgba(8,6,18,0.85)]">
{props.children}
</div>
</>
);
}

View File

@ -67,7 +67,7 @@ export function SearchBarInput(props: SearchBarProps) {
id: MWMediaType.SERIES,
name: t("searchBar.series"),
icon: Icons.CLAPPER_BOARD,
}
},
]}
onClick={() => setDropdownOpen((old) => !old)}
>

View File

@ -10,7 +10,7 @@ export interface EditButtonProps {
}
export function EditButton(props: EditButtonProps) {
const { t } = useTranslation()
const { t } = useTranslation();
const [parent] = useAutoAnimate<HTMLSpanElement>();
const onClick = useCallback(() => {
@ -24,7 +24,9 @@ export function EditButton(props: EditButtonProps) {
>
<span ref={parent}>
{props.editing ? (
<span className="mx-4 whitespace-nowrap">{t("media.stopEditing")}</span>
<span className="mx-4 whitespace-nowrap">
{t("media.stopEditing")}
</span>
) : (
<Icon icon={Icons.EDIT} />
)}

View File

@ -36,12 +36,13 @@ interface ErrorMessageProps {
}
export function ErrorMessage(props: ErrorMessageProps) {
const { t } = useTranslation()
const { t } = useTranslation();
return (
<div
className={`${props.localSize ? "h-full" : "min-h-screen"
} flex w-full flex-col items-center justify-center px-4 py-12`}
className={`${
props.localSize ? "h-full" : "min-h-screen"
} flex w-full flex-col items-center justify-center px-4 py-12`}
>
<div className="flex flex-col items-center justify-start text-center">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />

View File

@ -0,0 +1,25 @@
import { Overlay } from "@/components/Overlay";
import { ReactNode } from "react";
import { createPortal } from "react-dom";
interface Props {
show: boolean;
children?: ReactNode;
}
export function ModalFrame(props: { children?: ReactNode }) {
return <Overlay>{props.children}</Overlay>;
}
export function Modal(props: Props) {
if (!props.show) return null;
return createPortal(<ModalFrame>{props.children}</ModalFrame>, document.body);
}
export function ModalCard(props: { children?: ReactNode }) {
return (
<div className="relative w-4/5 max-w-[645px] overflow-hidden rounded-lg bg-denim-200 px-10 py-16">
{props.children}
</div>
);
}

View File

@ -35,12 +35,14 @@ function MediaCardContent({
return (
<div
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${canLink ? "hover:bg-opacity-100" : ""
}`}
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
canLink ? "hover:bg-opacity-100" : ""
}`}
>
<article
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${canLink ? "group-hover:scale-95" : ""
}`}
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
canLink ? "group-hover:scale-95" : ""
}`}
>
<div
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg"
@ -53,7 +55,7 @@ function MediaCardContent({
<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
episode: series.episode,
})}
</p>
</div>
@ -62,12 +64,14 @@ function MediaCardContent({
{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" : ""
}`}
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" : ""
}`}
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">
@ -83,8 +87,9 @@ function MediaCardContent({
) : null}
<div
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${closable ? "opacity-100" : "pointer-events-none opacity-0"
}`}
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
closable ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<IconPatch
clickable
@ -99,10 +104,7 @@ function MediaCardContent({
</h1>
<DotList
className="text-xs"
content={[
t(`media.${media.type}`),
media.year,
]}
content={[t(`media.${media.type}`), media.year]}
/>
</article>
</div>

View File

@ -44,10 +44,9 @@ const LazyLoadedApp = React.lazy(async () => {
function TheRouter(props: { children: ReactNode }) {
const normalRouter = conf().NORMAL_ROUTER;
if (normalRouter)
return <BrowserRouter children={props.children} />
return <HashRouter children={props.children} />
if (normalRouter) return <BrowserRouter>{props.children}</BrowserRouter>;
return <HashRouter>{props.children}</HashRouter>;
}
ReactDOM.render(

View File

@ -49,11 +49,13 @@ async function getMetas(
searchQuery: `${item.title} ${year}`,
type: item.mediaType,
});
const relevantItem = data.find((res) =>
yearsAreClose(Number(res.year), year) && compareTitle(res.title, item.title)
const relevantItem = data.find(
(res) =>
yearsAreClose(Number(res.year), year) &&
compareTitle(res.title, item.title)
);
if (!relevantItem) {
console.error("No item found for migration: " + item.title);
console.error(`No item found for migration: ${item.title}`);
return;
}
return {
@ -188,7 +190,10 @@ export async function migrateV2Videos(old: OldData) {
},
progress: oldWatched.progress,
percentage: oldWatched.percentage,
watchedAt: now + Number(oldWatched.seasonId) * 1000 + Number(oldWatched.episodeId), // There was no watchedAt in V2
watchedAt:
now +
Number(oldWatched.seasonId) * 1000 +
Number(oldWatched.episodeId), // There was no watchedAt in V2
// JANK ALERT: Put watchedAt in the future to show last episode as most recently
};

View File

@ -1,7 +1,11 @@
function normalizeTitle(title: string): string {
return title.trim().toLowerCase().replace(/[\'\"\:]/g, "").replace(/[^a-zA-Z0-9]+/g, "_");
return title
.trim()
.toLowerCase()
.replace(/['":]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "_");
}
export function compareTitle(a: string, b: string): boolean {
return normalizeTitle(a) === normalizeTitle(b);
return normalizeTitle(a) === normalizeTitle(b);
}

View File

@ -11,7 +11,7 @@ interface Props {
}
export function CaptionsSelectionAction(props: Props) {
const { t } = useTranslation()
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const { isMobile } = useIsMobile();
@ -22,7 +22,7 @@ export function CaptionsSelectionAction(props: Props) {
<PopoutAnchor for="captions">
<VideoPlayerIconButton
className={props.className}
text={isMobile ? t("videoPlayer.buttons.captions") as string : ""}
text={isMobile ? (t("videoPlayer.buttons.captions") as string) : ""}
wide={isMobile}
onClick={() => controls.openPopout("captions")}
icon={Icons.CAPTIONS}

View File

@ -9,7 +9,9 @@ export function PageTitleAction() {
if (!meta) return null;
const title = isSeries ? `${meta.meta.title} - ${humanizedEpisodeId}` : meta.meta.title;
const title = isSeries
? `${meta.meta.title} - ${humanizedEpisodeId}`
: meta.meta.title;
return (
<Helmet>

View File

@ -13,7 +13,7 @@ interface Props {
}
export function SeriesSelectionAction(props: Props) {
const { t } = useTranslation()
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const meta = useMeta(descriptor);
const videoInterface = useInterface(descriptor);

View File

@ -11,7 +11,7 @@ interface Props {
}
export function SourceSelectionAction(props: Props) {
const { t } = useTranslation()
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const videoInterface = useInterface(descriptor);
const controls = useControls(descriptor);

View File

@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
export function useCurrentSeriesEpisodeInfo(descriptor: string) {
const meta = useMeta(descriptor);
const {t} = useTranslation()
const { t } = useTranslation();
const currentSeasonInfo = useMemo(() => {
return meta?.seasons?.find(
@ -24,10 +24,10 @@ export function useCurrentSeriesEpisodeInfo(descriptor: string) {
);
if (!isSeries) return { isSeries: false };
const humanizedEpisodeId = t("videoPlayer.seasonAndEpisode", {
season: currentSeasonInfo?.number,
episode: currentEpisodeInfo?.number
episode: currentEpisodeInfo?.number,
});
return {

View File

@ -15,7 +15,7 @@ function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
}
export function CaptionSelectionPopout() {
const { t } = useTranslation()
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const meta = useMeta(descriptor);

View File

@ -11,14 +11,14 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";
import { useControls } from "@/video/state/logic/controls";
import { useWatchedContext } from "@/state/watched";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
export function EpisodeSelectionPopout() {
const params = useParams<{
media: string;
}>();
const { t } = useTranslation()
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const meta = useMeta(descriptor);
@ -61,7 +61,7 @@ export function EpisodeSelectionPopout() {
// race condition, jank solution but it works.
setTimeout(() => {
controls.setCurrentEpisode(seasonId, episodeId);
}, 100)
}, 100);
},
[controls]
);
@ -141,15 +141,15 @@ export function EpisodeSelectionPopout() {
>
{currentSeasonInfo
? meta?.seasons?.map?.((season) => (
<PopoutListEntry
key={season.id}
active={meta?.episode?.seasonId === season.id}
onClick={() => setSeason(season.id)}
isOnDarkBackground
>
{season.title}
</PopoutListEntry>
))
<PopoutListEntry
key={season.id}
active={meta?.episode?.seasonId === season.id}
onClick={() => setSeason(season.id)}
isOnDarkBackground
>
{season.title}
</PopoutListEntry>
))
: "No season"}
</PopoutSection>
<PopoutSection className="relative h-full overflow-y-auto">
@ -166,7 +166,7 @@ export function EpisodeSelectionPopout() {
/>
<p className="mt-6 w-full text-center">
{t("videoPLayer.popouts.errors.loadingWentWrong", {
seasonTitle: currentSeasonInfo?.title?.toLowerCase()
seasonTitle: currentSeasonInfo?.title?.toLowerCase(),
})}
</p>
</div>
@ -175,29 +175,29 @@ export function EpisodeSelectionPopout() {
<div>
{currentSeasonEpisodes && currentSeasonInfo
? currentSeasonEpisodes.map((e) => (
<PopoutListEntry
key={e.id}
active={e.id === meta?.episode?.episodeId}
onClick={() => {
if (e.id === meta?.episode?.episodeId)
controls.closePopout();
else setCurrent(currentSeasonInfo.id, e.id);
}}
percentageCompleted={
watched.items.find(
(item) =>
item.item?.series?.seasonId ===
currentSeasonInfo.id &&
item.item?.series?.episodeId === e.id
)?.percentage
}
>
{t("videoPlayer.popouts.episode", {
index: e.number,
title: e.title
})}
</PopoutListEntry>
))
<PopoutListEntry
key={e.id}
active={e.id === meta?.episode?.episodeId}
onClick={() => {
if (e.id === meta?.episode?.episodeId)
controls.closePopout();
else setCurrent(currentSeasonInfo.id, e.id);
}}
percentageCompleted={
watched.items.find(
(item) =>
item.item?.series?.seasonId ===
currentSeasonInfo.id &&
item.item?.series?.episodeId === e.id
)?.percentage
}
>
{t("videoPlayer.popouts.episode", {
index: e.number,
title: e.title,
})}
</PopoutListEntry>
))
: "No episodes"}
</div>
)}

View File

@ -7,12 +7,15 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta";
import { useControls } from "@/video/state/logic/controls";
import { MWStream } from "@/backend/helpers/streams";
import { getEmbedScraperByType, getProviders } from "@/backend/helpers/register";
import {
getEmbedScraperByType,
getProviders,
} from "@/backend/helpers/register";
import { runEmbedScraper, runProvider } from "@/backend/helpers/run";
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
import { useTranslation } from "react-i18next";
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
interface EmbedEntryProps {
name: string;
@ -24,35 +27,39 @@ interface EmbedEntryProps {
export function EmbedEntry(props: EmbedEntryProps) {
const [scrapeEmbed, loading, error] = useLoading(async () => {
const scraper = getEmbedScraperByType(props.type);
if (!scraper) throw new Error("Embed scraper not found")
if (!scraper) throw new Error("Embed scraper not found");
const stream = await runEmbedScraper(scraper, {
progress: () => { }, // no progress tracking for inline scraping
progress: () => {}, // no progress tracking for inline scraping
url: props.url,
})
});
props.onSelect(stream);
});
return (<PopoutListEntry
isOnDarkBackground
loading={loading}
errored={!!error}
onClick={() => {
scrapeEmbed();
}}
>
{props.name}
</PopoutListEntry>)
return (
<PopoutListEntry
isOnDarkBackground
loading={loading}
errored={!!error}
onClick={() => {
scrapeEmbed();
}}
>
{props.name}
</PopoutListEntry>
);
}
export function SourceSelectionPopout() {
const { t } = useTranslation()
const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const meta = useMeta(descriptor);
const providers = useMemo(
() =>
meta ? getProviders().filter((v) => v.type.includes(meta.meta.meta.type)) : [],
meta
? getProviders().filter((v) => v.type.includes(meta.meta.meta.type))
: [],
[meta]
);
@ -71,7 +78,7 @@ export function SourceSelectionPopout() {
if (!meta) throw new Error("need meta");
return runProvider(theProvider, {
media: meta.meta,
progress: () => { },
progress: () => {},
type: meta.meta.meta.type,
episode: meta.episode?.episodeId as any,
season: meta.episode?.seasonId as any,
@ -110,13 +117,13 @@ export function SourceSelectionPopout() {
const realStream = v.stream;
if (!realStream) {
const embed = v?.embeds[0];
if (!embed) throw new Error("Embed scraper not found")
if (!embed) throw new Error("Embed scraper not found");
const scraper = getEmbedScraperByType(embed.type);
if (!scraper) throw new Error("Embed scraper not found")
if (!scraper) throw new Error("Embed scraper not found");
const stream = await runEmbedScraper(scraper, {
progress: () => { }, // no progress tracking for inline scraping
progress: () => {}, // no progress tracking for inline scraping
url: embed.url,
})
});
selectSource(stream);
return;
}
@ -142,28 +149,30 @@ export function SourceSelectionPopout() {
const embeds = scrapeResult?.embeds || [];
// Count embed types to determine if it should show a number behind the name
const embedsPerType: Record<string, (MWEmbed & { displayName: string })[]> = {}
const embedsPerType: Record<string, (MWEmbed & { displayName: string })[]> =
{};
for (const embed of embeds) {
if (!embed.type) continue;
if (!embedsPerType[embed.type]) embedsPerType[embed.type] = [];
embedsPerType[embed.type].push({
...embed,
displayName: embed.type
})
displayName: embed.type,
});
}
const embedsRes = Object.entries(embedsPerType).flatMap(([type, entries]) => {
if (entries.length > 1) return entries.map((embed, i) => ({
...embed,
displayName: `${embed.type} ${i + 1}`
}))
const embedsRes = Object.entries(embedsPerType).flatMap(([_, entries]) => {
if (entries.length > 1)
return entries.map((embed, i) => ({
...embed,
displayName: `${embed.type} ${i + 1}`,
}));
return entries;
})
});
console.log(embedsRes)
console.log(embedsRes);
return embedsRes;
}, [scrapeResult?.embeds])
}, [scrapeResult?.embeds]);
return (
<>
@ -234,27 +243,31 @@ export function SourceSelectionPopout() {
Native source
</PopoutListEntry>
) : null}
{(visibleEmbeds?.length || 0) > 0 ? visibleEmbeds?.map((v) => (
<EmbedEntry
type={v.type}
name={v.displayName ?? ""}
key={v.url}
url={v.url}
onSelect={(stream) => {
selectSource(stream);
}}
/>
)) : (<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col flex-wrap items-center text-slate-400">
<IconPatch
icon={Icons.EYE_SLASH}
className="text-xl text-bink-600"
{(visibleEmbeds?.length || 0) > 0 ? (
visibleEmbeds?.map((v) => (
<EmbedEntry
type={v.type}
name={v.displayName ?? ""}
key={v.url}
url={v.url}
onSelect={(stream) => {
selectSource(stream);
}}
/>
<p className="mt-6 w-full text-center">
{t("videoPlayer.popouts.noEmbeds")}
</p>
))
) : (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col flex-wrap items-center text-slate-400">
<IconPatch
icon={Icons.EYE_SLASH}
className="text-xl text-bink-600"
/>
<p className="mt-6 w-full text-center">
{t("videoPlayer.popouts.noEmbeds")}
</p>
</div>
</div>
</div>)}
)}
</>
)}
</PopoutSection>

View File

@ -4,7 +4,6 @@ import {
MWStreamType,
} from "@/backend/helpers/streams";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaMeta } from "@/backend/metadata/types";
import Hls from "hls.js";
import { VideoPlayerStateProvider } from "./providers/providerTypes";

View File

@ -1,14 +1,11 @@
import { MWMediaMeta } from "@/backend/metadata/types";
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
import { Link } from "@/components/text/Link";
import { useGoBack } from "@/hooks/useGoBack";
import { conf } from "@/setup/config";
import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader";
import { Helmet } from "react-helmet";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
export function MediaFetchErrorView() {
const { t } = useTranslation()
const { t } = useTranslation();
const goBack = useGoBack();
return (
@ -20,9 +17,7 @@ export function MediaFetchErrorView() {
<VideoPlayerHeader onClick={goBack} />
</div>
<ErrorMessage>
<p className="my-6 max-w-lg">
{t("media.errors.mediaFailed")}
</p>
<p className="my-6 max-w-lg">{t("media.errors.mediaFailed")}</p>
</ErrorMessage>
</div>
);

View File

@ -19,13 +19,13 @@ import { ProgressListenerController } from "@/video/components/controllers/Progr
import { VideoPlayerMeta } from "@/video/state/types";
import { SeriesController } from "@/video/components/controllers/SeriesController";
import { useWatchedItem } from "@/state/watched";
import { useTranslation } from "react-i18next";
import { MediaFetchErrorView } from "./MediaErrorView";
import { MediaScrapeLog } from "./MediaScrapeLog";
import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
import { useTranslation } from "react-i18next";
function MediaViewLoading(props: { onGoBack(): void }) {
const { t } = useTranslation()
const { t } = useTranslation();
return (
<div className="relative flex h-screen items-center justify-center">
@ -37,7 +37,9 @@ function MediaViewLoading(props: { onGoBack(): void }) {
</div>
<div className="flex flex-col items-center">
<Loading className="mb-4" />
<p className="mb-8 text-denim-700">{t("videoPlayer.findingBestVideo")}</p>
<p className="mb-8 text-denim-700">
{t("videoPlayer.findingBestVideo")}
</p>
</div>
</div>
);
@ -51,7 +53,7 @@ interface MediaViewScrapingProps {
}
function MediaViewScraping(props: MediaViewScrapingProps) {
const { eventLog, stream, pending } = useScrape(props.meta, props.selected);
const { t } = useTranslation()
const { t } = useTranslation();
useEffect(() => {
if (stream) {
@ -78,14 +80,13 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
) : (
<>
<IconPatch icon={Icons.EYE_SLASH} className="mb-8 text-bink-700" />
<p className="mb-8 text-denim-700">
{t("videoPlayer.noVideos")}
</p>
<p className="mb-8 text-denim-700">{t("videoPlayer.noVideos")}</p>
</>
)}
<div
className={`flex flex-col items-center transition-opacity duration-200 ${pending ? "opacity-100" : "opacity-0"
}`}
className={`flex flex-col items-center transition-opacity duration-200 ${
pending ? "opacity-100" : "opacity-0"
}`}
>
<MediaScrapeLog events={eventLog} />
</div>

View File

@ -13,7 +13,7 @@ export function NotFoundWrapper(props: {
children?: ReactNode;
video?: boolean;
}) {
const { t } = useTranslation()
const { t } = useTranslation();
const goBack = useGoBack();
return (

View File

@ -2,60 +2,66 @@ import { useEffect, useState } from "react";
import pako from "pako";
function fromBinary(str: string): Uint8Array {
let result = new Uint8Array(str.length);
[...str].forEach((char, i) => {
result[i] = char.charCodeAt(0);
});
return result;
const result = new Uint8Array(str.length);
[...str].forEach((char, i) => {
result[i] = char.charCodeAt(0);
});
return result;
}
export function V2MigrationView() {
const [done, setDone] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search ?? "");
if (!params.has("m-time") || !params.has("m-data")) {
// migration params missing, just redirect
setDone(true);
return;
}
const [done, setDone] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search ?? "");
if (!params.has("m-time") || !params.has("m-data")) {
// migration params missing, just redirect
setDone(true);
return;
}
const data = JSON.parse(pako.inflate(fromBinary(atob(params.get("m-data") as string)), { to: "string" }));
const timeOfMigration = new Date(params.get("m-time") as string);
const data = JSON.parse(
pako.inflate(fromBinary(atob(params.get("m-data") as string)), {
to: "string",
})
);
const timeOfMigration = new Date(params.get("m-time") as string);
const savedTime = localStorage.getItem("mw-migration-date");
if (savedTime) {
if (new Date(savedTime) >= timeOfMigration) {
// has already migrated this or something newer, skip
setDone(true);
return;
}
}
// restore migration data
if (data.bookmarks)
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks))
if (data.videoProgress)
localStorage.setItem("video-progress", JSON.stringify(data.videoProgress))
localStorage.setItem("mw-migration-date", timeOfMigration.toISOString())
// finished
const savedTime = localStorage.getItem("mw-migration-date");
if (savedTime) {
if (new Date(savedTime) >= timeOfMigration) {
// has already migrated this or something newer, skip
setDone(true);
}, [])
// redirect when done
useEffect(() => {
if (!done) return;
const newUrl = new URL(window.location.href);
return;
}
}
const newParams = [] as string[];
newUrl.searchParams.forEach((_, key)=>newParams.push(key));
newParams.forEach(v => newUrl.searchParams.delete(v))
// restore migration data
if (data.bookmarks)
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks));
if (data.videoProgress)
localStorage.setItem(
"video-progress",
JSON.stringify(data.videoProgress)
);
localStorage.setItem("mw-migration-date", timeOfMigration.toISOString());
newUrl.hash = "";
// finished
setDone(true);
}, []);
window.location.href = newUrl.toString();
}, [done])
// redirect when done
useEffect(() => {
if (!done) return;
const newUrl = new URL(window.location.href);
return null;
const newParams = [] as string[];
newUrl.searchParams.forEach((_, key) => newParams.push(key));
newParams.forEach((v) => newUrl.searchParams.delete(v));
newUrl.hash = "";
window.location.href = newUrl.toString();
}, [done]);
return null;
}

View File

@ -1,17 +1,18 @@
import { useTranslation } from "react-i18next";
import { Loading } from "@/components/layout/Loading";
import { MWQuery } from "@/backend/metadata/types";
import { useSearchQuery } from "@/hooks/useSearchQuery";
export function SearchLoadingView() {
const { t } = useTranslation();
const [query] = useSearchQuery()
const [query] = useSearchQuery();
return (
<>
<Loading
className="mt-40 mb-24 "
text={t(`search.loading_${query.type}`) || t("search.loading") || "Fetching your favourite shows..."}
/>
</>
<Loading
className="mt-40 mb-24 "
text={
t(`search.loading_${query.type}`) ||
t("search.loading") ||
"Fetching your favourite shows..."
}
/>
);
}

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import Sticky from "react-stickynode";
import { useTranslation } from "react-i18next";
import { Navigation } from "@/components/layout/Navigation";

View File

@ -1,7 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import loadVersion from "vite-plugin-package-version";
import checker from 'vite-plugin-checker'
import checker from "vite-plugin-checker";
import path from "path";
export default defineConfig({
@ -10,7 +10,14 @@ export default defineConfig({
loadVersion(),
checker({
typescript: true, // check typescript build errors in dev server
})
eslint: {
// check lint errors in dev server
lintCommand: "eslint --ext .tsx,.ts src",
dev: {
logLevel: ["error"],
},
},
}),
],
resolve: {
alias: {