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-await-in-loop": "off",
"no-nested-ternary": "off", "no-nested-ternary": "off",
"prefer-destructuring": "off", "prefer-destructuring": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"react/jsx-filename-extension": [ "react/jsx-filename-extension": [
"error", "error",
{ extensions: [".js", ".tsx", ".jsx"] } { extensions: [".js", ".tsx", ".jsx"] }

View File

@ -1,20 +1,19 @@
import { MWEmbedType } from "@/backend/helpers/embed"; import { MWEmbedType } from "@/backend/helpers/embed";
import { MWMediaType } from "../metadata/types";
import { registerEmbedScraper } from "@/backend/helpers/register"; import { registerEmbedScraper } from "@/backend/helpers/register";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
registerEmbedScraper({ registerEmbedScraper({
id: "playm4u", id: "playm4u",
displayName: "playm4u", displayName: "playm4u",
for: MWEmbedType.PLAYM4U, for: MWEmbedType.PLAYM4U,
rank: 0, rank: 0,
async getStream(ctx) { async getStream() {
// throw new Error("Oh well 2") // throw new Error("Oh well 2")
return { return {
streamUrl: '', streamUrl: "",
quality: MWStreamQuality.Q1080P, quality: MWStreamQuality.Q1080P,
captions: [], captions: [],
type: MWStreamType.MP4, type: MWStreamType.MP4,
}; };
}, },
}) });

View File

@ -1,60 +1,65 @@
import { MWEmbedType } from "@/backend/helpers/embed"; import { MWEmbedType } from "@/backend/helpers/embed";
import { MWMediaType } from "../metadata/types";
import { registerEmbedScraper } from "@/backend/helpers/register"; 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"; import { proxiedFetch } from "@/backend/helpers/fetch";
const HOST = 'streamm4u.club'; const HOST = "streamm4u.club";
const URL_BASE = `https://${HOST}`; const URL_BASE = `https://${HOST}`;
const URL_API = `${URL_BASE}/api`; const URL_API = `${URL_BASE}/api`;
const URL_API_SOURCE = `${URL_API}/source`; 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 // TODO check out 403 / 404 on successfully returned video stream URLs
registerEmbedScraper({ registerEmbedScraper({
id: "streamm4u", id: "streamm4u",
displayName: "streamm4u", displayName: "streamm4u",
for: MWEmbedType.STREAMM4U, for: MWEmbedType.STREAMM4U,
rank: 100, rank: 100,
async getStream({ progress, url }) { async getStream({ progress, url }) {
// const scrapingThreads = [];
// const streams = [];
const scrapingThreads = []; const sources = (await scrape(url)).sort(
let streams = []; (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", ""))); if (!preferredSource) throw new Error("No source found");
let preferredSourceIndex = 0;
let preferredSource = sources[0];
if (!preferredSource) throw new Error("No source found") progress(100);
progress(100) return preferredSource;
},
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;
}

View File

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

View File

@ -25,15 +25,15 @@ type MWProviderRunContextBase = {
}; };
type MWProviderRunContextTypeSpecific = type MWProviderRunContextTypeSpecific =
| { | {
type: MWMediaType.MOVIE | MWMediaType.ANIME; type: MWMediaType.MOVIE | MWMediaType.ANIME;
episode: undefined; episode: undefined;
season: undefined; season: undefined;
} }
| { | {
type: MWMediaType.SERIES; type: MWMediaType.SERIES;
episode: string; episode: string;
season: string; season: string;
}; };
export type MWProviderRunContext = MWProviderRunContextBase & export type MWProviderRunContext = MWProviderRunContextBase &
MWProviderRunContextTypeSpecific; MWProviderRunContextTypeSpecific;
@ -50,7 +50,7 @@ async function findBestEmbedStream(
embedNum += 1; embedNum += 1;
if (!embed.type) continue; if (!embed.type) continue;
const scraper = getEmbedScraperByType(embed.type); 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("|"); const eventId = [providerId, scraper.id, embedNum].join("|");

View File

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

View File

@ -21,7 +21,10 @@ registerProvider({
} }
); );
const foundItem = searchResults.results.find((v: any) => { 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"); if (!foundItem) throw new Error("No watchable item found");
const flixId = foundItem.id; const flixId = foundItem.id;

View File

@ -1,11 +1,9 @@
import { compareTitle } from "@/utils/titleMatch"; import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
import { MWEmbedType } from "../helpers/embed";
import { proxiedFetch } from "../helpers/fetch"; import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register"; import { registerProvider } from "../helpers/register";
import { MWMediaType } from "../metadata/types"; 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_BASE = `https://${HOST}`;
const URL_SEARCH = `${URL_BASE}/search`; const URL_SEARCH = `${URL_BASE}/search`;
const URL_AJAX = `${URL_BASE}/ajax`; 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*)/; const REGEX_SEASON_EPISODE = /S(\d*)-E(\d*)/;
function toDom(html: string) { function toDom(html: string) {
return new DOMParser().parseFromString(html, "text/html") return new DOMParser().parseFromString(html, "text/html");
} }
registerProvider({ 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. 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], type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, progress, type, episode: episodeId, season: seasonId }) { async scrape({ media, type, episode: episodeId, season: seasonId }) {
const season = media.meta.seasons?.find(s => s.id === seasonId)?.number || 1 const season =
const episode = media.meta.type === MWMediaType.SERIES ? media.meta.seasonData.episodes.find(ep => ep.id === episodeId)?.number || 1 : undefined 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[] = []; const embeds: MWEmbed[] = [];
@ -43,39 +46,49 @@ registerProvider({
responseType: "text" as any, 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); let dom = toDom(responseText);
const searchResults = [...dom.querySelectorAll('.item')].map(element => { const searchResults = [...dom.querySelectorAll(".item")]
const tooltipText = element.querySelector('.tiptitle p')?.innerHTML; .map((element) => {
if (!tooltipText) return; 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]) { if (!regexResult || !regexResult[1] || !regexResult[2]) {
return; return;
} }
const title = regexResult[1]; 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 year = Number(regexResult[2].slice(0, 4)); // * Some media stores the start AND end year. Only need start year
const a = element.querySelector('a'); const a = element.querySelector("a");
if (!a) return; if (!a) return;
const href = a.href; const href = a.href;
regexResult = REGEX_TYPE.exec(href); regexResult = REGEX_TYPE.exec(href);
if (!regexResult || !regexResult[1]) { if (!regexResult || !regexResult[1]) {
return; 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 }; return { type: scraperDeterminedType, title, year, href };
}).filter(item => item); })
.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) { if (!mediaInResults) {
// * Nothing found // * Nothing found
@ -84,109 +97,124 @@ registerProvider({
}; };
} }
let cookies: string | null = ''; let cookies: string | null = "";
const responseTextFromMedia = await proxiedFetch<string>(mediaInResults.href, { const responseTextFromMedia = await proxiedFetch<string>(
onResponse(context) { mediaInResults.href,
cookies = context.response.headers.get('X-Set-Cookie') {
}, onResponse(context) {
}); cookies = context.response.headers.get("X-Set-Cookie");
},
}
);
dom = toDom(responseTextFromMedia); dom = toDom(responseTextFromMedia);
let regexResult = REGEX_COOKIES.exec(cookies); let regexResult = REGEX_COOKIES.exec(cookies);
if (!regexResult || !regexResult[1] || !regexResult[2]) { if (!regexResult || !regexResult[1] || !regexResult[2]) {
// * DO SOMETHING? // * 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 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 (!token) return { embeds };
if (type === MWMediaType.SERIES) { if (type === MWMediaType.SERIES) {
// * Get the season/episode data // * Get the season/episode data
const episodes = [...dom.querySelectorAll('.episode')].map(element => { const episodes = [...dom.querySelectorAll(".episode")]
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML); .map((element) => {
regexResult = REGEX_SEASON_EPISODE.exec(element.innerHTML);
if (!regexResult || !regexResult[1] || !regexResult[2]) { if (!regexResult || !regexResult[1] || !regexResult[2]) {
return; return;
} }
const episode = Number(regexResult[1]); const newEpisode = Number(regexResult[1]);
const season = Number(regexResult[2]); const newSeason = Number(regexResult[2]);
return { return {
id: element.getAttribute('idepisode'), id: element.getAttribute("idepisode"),
episode: episode, episode: newEpisode,
season: season season: newSeason,
}; };
}).filter(item => item); })
.filter((item) => item);
const ep = episodes.find(ep => ep && ep.episode === episode && ep.season === season); const ep = episodes.find(
if (!ep) return { embeds } (newEp) => newEp && newEp.episode === episode && newEp.season === season
);
if (!ep) return { embeds };
const form = `idepisode=${ep.id}&_token=${token}`; const form = `idepisode=${ep.id}&_token=${token}`;
let response = await proxiedFetch<string>(URL_AJAX_TV, { const response = await proxiedFetch<string>(URL_AJAX_TV, {
method: 'POST', method: "POST",
headers: { headers: {
'Accept': '*/*', Accept: "*/*",
'Accept-Encoding': 'gzip, deflate, br', "Accept-Encoding": "gzip, deflate, br",
'Accept-Language': "en-US,en;q=0.9", "Accept-Language": "en-US,en;q=0.9",
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
'X-Requested-With': 'XMLHttpRequest', "X-Requested-With": "XMLHttpRequest",
'Sec-CH-UA': '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"', "Sec-CH-UA":
'Sec-CH-UA-Mobile': '?0', '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
'Sec-CH-UA-Platform': '"Linux"', "Sec-CH-UA-Mobile": "?0",
'Sec-Fetch-Site': 'same-origin', "Sec-CH-UA-Platform": '"Linux"',
'Sec-Fetch-Mode': 'cors', "Sec-Fetch-Site": "same-origin",
'Sec-Fetch-Dest': 'empty', "Sec-Fetch-Mode": "cors",
'X-Cookie': cookieHeader, "Sec-Fetch-Dest": "empty",
'X-Origin': URL_BASE, "X-Cookie": cookieHeader,
'X-Referer': mediaInResults.href "X-Origin": URL_BASE,
"X-Referer": mediaInResults.href,
}, },
body: form body: form,
}); });
dom = toDom(response); 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) { for (const server of servers) {
const form = `m4u=${server}&_token=${token}`; const form = `m4u=${server}&_token=${token}`;
const response = await proxiedFetch<string>(URL_AJAX, { const response = await proxiedFetch<string>(URL_AJAX, {
method: 'POST', method: "POST",
headers: { headers: {
'Accept': '*/*', Accept: "*/*",
'Accept-Encoding': 'gzip, deflate, br', "Accept-Encoding": "gzip, deflate, br",
'Accept-Language': "en-US,en;q=0.9", "Accept-Language": "en-US,en;q=0.9",
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
'X-Requested-With': 'XMLHttpRequest', "X-Requested-With": "XMLHttpRequest",
'Sec-CH-UA': '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"', "Sec-CH-UA":
'Sec-CH-UA-Mobile': '?0', '"Not?A_Brand";v="8", "Chromium";v="108", "Microsoft Edge";v="108"',
'Sec-CH-UA-Platform': '"Linux"', "Sec-CH-UA-Mobile": "?0",
'Sec-Fetch-Site': 'same-origin', "Sec-CH-UA-Platform": '"Linux"',
'Sec-Fetch-Mode': 'cors', "Sec-Fetch-Site": "same-origin",
'Sec-Fetch-Dest': 'empty', "Sec-Fetch-Mode": "cors",
'X-Cookie': cookieHeader, "Sec-Fetch-Dest": "empty",
'X-Origin': URL_BASE, "X-Cookie": cookieHeader,
'X-Referer': mediaInResults.href "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) => { const getEmbedType = (url: string) => {
if (url.startsWith("https://streamm4u.club")) return MWEmbedType.STREAMM4U if (url.startsWith("https://streamm4u.club"))
if (url.startsWith("https://play.playm4u.xyz")) return MWEmbedType.PLAYM4U return MWEmbedType.STREAMM4U;
if (url.startsWith("https://play.playm4u.xyz"))
return MWEmbedType.PLAYM4U;
return null; return null;
} };
if (!link) continue; if (!link) continue;
@ -194,15 +222,14 @@ registerProvider({
if (embedType) { if (embedType) {
embeds.push({ embeds.push({
url: link, url: link,
type: embedType type: embedType,
}) });
}; }
} }
console.log(embeds); console.log(embeds);
return { return {
embeds, embeds,
} };
},
}
}); });

View File

@ -131,7 +131,8 @@ registerProvider({
const superstreamEntry = searchRes.find( const superstreamEntry = searchRes.find(
(res: any) => (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"); 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, id: MWMediaType.SERIES,
name: t("searchBar.series"), name: t("searchBar.series"),
icon: Icons.CLAPPER_BOARD, icon: Icons.CLAPPER_BOARD,
} },
]} ]}
onClick={() => setDropdownOpen((old) => !old)} onClick={() => setDropdownOpen((old) => !old)}
> >

View File

@ -10,7 +10,7 @@ export interface EditButtonProps {
} }
export function EditButton(props: EditButtonProps) { export function EditButton(props: EditButtonProps) {
const { t } = useTranslation() const { t } = useTranslation();
const [parent] = useAutoAnimate<HTMLSpanElement>(); const [parent] = useAutoAnimate<HTMLSpanElement>();
const onClick = useCallback(() => { const onClick = useCallback(() => {
@ -24,7 +24,9 @@ export function EditButton(props: EditButtonProps) {
> >
<span ref={parent}> <span ref={parent}>
{props.editing ? ( {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} /> <Icon icon={Icons.EDIT} />
)} )}

View File

@ -36,12 +36,13 @@ interface ErrorMessageProps {
} }
export function ErrorMessage(props: ErrorMessageProps) { export function ErrorMessage(props: ErrorMessageProps) {
const { t } = useTranslation() const { t } = useTranslation();
return ( return (
<div <div
className={`${props.localSize ? "h-full" : "min-h-screen" className={`${
} flex w-full flex-col items-center justify-center px-4 py-12`} 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"> <div className="flex flex-col items-center justify-start text-center">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" /> <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 ( return (
<div <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 <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 <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" 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"> <p className="text-center text-xs font-bold text-slate-400 transition-colors group-hover:text-white">
{t("seasons.seasonAndEpisode", { {t("seasons.seasonAndEpisode", {
season: series.season, season: series.season,
episode: series.episode episode: series.episode,
})} })}
</p> </p>
</div> </div>
@ -62,12 +64,14 @@ function MediaCardContent({
{percentage !== undefined ? ( {percentage !== undefined ? (
<> <>
<div <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 <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="absolute inset-x-0 bottom-0 p-3">
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600"> <div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
@ -83,8 +87,9 @@ function MediaCardContent({
) : null} ) : null}
<div <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 <IconPatch
clickable clickable
@ -99,10 +104,7 @@ function MediaCardContent({
</h1> </h1>
<DotList <DotList
className="text-xs" className="text-xs"
content={[ content={[t(`media.${media.type}`), media.year]}
t(`media.${media.type}`),
media.year,
]}
/> />
</article> </article>
</div> </div>

View File

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

View File

@ -49,11 +49,13 @@ async function getMetas(
searchQuery: `${item.title} ${year}`, searchQuery: `${item.title} ${year}`,
type: item.mediaType, type: item.mediaType,
}); });
const relevantItem = data.find((res) => const relevantItem = data.find(
yearsAreClose(Number(res.year), year) && compareTitle(res.title, item.title) (res) =>
yearsAreClose(Number(res.year), year) &&
compareTitle(res.title, item.title)
); );
if (!relevantItem) { if (!relevantItem) {
console.error("No item found for migration: " + item.title); console.error(`No item found for migration: ${item.title}`);
return; return;
} }
return { return {
@ -188,7 +190,10 @@ export async function migrateV2Videos(old: OldData) {
}, },
progress: oldWatched.progress, progress: oldWatched.progress,
percentage: oldWatched.percentage, 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 // 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 { 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 { 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) { export function CaptionsSelectionAction(props: Props) {
const { t } = useTranslation() const { t } = useTranslation();
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor); const controls = useControls(descriptor);
const { isMobile } = useIsMobile(); const { isMobile } = useIsMobile();
@ -22,7 +22,7 @@ export function CaptionsSelectionAction(props: Props) {
<PopoutAnchor for="captions"> <PopoutAnchor for="captions">
<VideoPlayerIconButton <VideoPlayerIconButton
className={props.className} className={props.className}
text={isMobile ? t("videoPlayer.buttons.captions") as string : ""} text={isMobile ? (t("videoPlayer.buttons.captions") as string) : ""}
wide={isMobile} wide={isMobile}
onClick={() => controls.openPopout("captions")} onClick={() => controls.openPopout("captions")}
icon={Icons.CAPTIONS} icon={Icons.CAPTIONS}

View File

@ -9,7 +9,9 @@ export function PageTitleAction() {
if (!meta) return null; 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 ( return (
<Helmet> <Helmet>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,60 +2,66 @@ import { useEffect, useState } from "react";
import pako from "pako"; import pako from "pako";
function fromBinary(str: string): Uint8Array { function fromBinary(str: string): Uint8Array {
let result = new Uint8Array(str.length); const result = new Uint8Array(str.length);
[...str].forEach((char, i) => { [...str].forEach((char, i) => {
result[i] = char.charCodeAt(0); result[i] = char.charCodeAt(0);
}); });
return result; return result;
} }
export function V2MigrationView() { export function V2MigrationView() {
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search ?? ""); const params = new URLSearchParams(window.location.search ?? "");
if (!params.has("m-time") || !params.has("m-data")) { if (!params.has("m-time") || !params.has("m-data")) {
// migration params missing, just redirect // migration params missing, just redirect
setDone(true); setDone(true);
return; return;
} }
const data = JSON.parse(pako.inflate(fromBinary(atob(params.get("m-data") as string)), { to: "string" })); const data = JSON.parse(
const timeOfMigration = new Date(params.get("m-time") as string); 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"); const savedTime = localStorage.getItem("mw-migration-date");
if (savedTime) { if (savedTime) {
if (new Date(savedTime) >= timeOfMigration) { if (new Date(savedTime) >= timeOfMigration) {
// has already migrated this or something newer, skip // 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
setDone(true); setDone(true);
}, []) return;
}
// redirect when done }
useEffect(() => {
if (!done) return;
const newUrl = new URL(window.location.href);
const newParams = [] as string[]; // restore migration data
newUrl.searchParams.forEach((_, key)=>newParams.push(key)); if (data.bookmarks)
newParams.forEach(v => newUrl.searchParams.delete(v)) 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(); // redirect when done
}, [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 { useTranslation } from "react-i18next";
import { Loading } from "@/components/layout/Loading"; import { Loading } from "@/components/layout/Loading";
import { MWQuery } from "@/backend/metadata/types";
import { useSearchQuery } from "@/hooks/useSearchQuery"; import { useSearchQuery } from "@/hooks/useSearchQuery";
export function SearchLoadingView() { export function SearchLoadingView() {
const { t } = useTranslation(); const { t } = useTranslation();
const [query] = useSearchQuery() const [query] = useSearchQuery();
return ( return (
<> <Loading
<Loading className="mt-40 mb-24 "
className="mt-40 mb-24 " text={
text={t(`search.loading_${query.type}`) || t("search.loading") || "Fetching your favourite shows..."} 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 Sticky from "react-stickynode";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Navigation } from "@/components/layout/Navigation"; import { Navigation } from "@/components/layout/Navigation";

View File

@ -1,7 +1,7 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import loadVersion from "vite-plugin-package-version"; import loadVersion from "vite-plugin-package-version";
import checker from 'vite-plugin-checker' import checker from "vite-plugin-checker";
import path from "path"; import path from "path";
export default defineConfig({ export default defineConfig({
@ -10,7 +10,14 @@ export default defineConfig({
loadVersion(), loadVersion(),
checker({ checker({
typescript: true, // check typescript build errors in dev server 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: { resolve: {
alias: { alias: {