mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-29 16:07:40 +01:00
opush
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
51b7305799
commit
e267482d33
12 changed files with 257 additions and 25 deletions
|
@ -1,4 +1,5 @@
|
||||||
import { gql, request } from "graphql-request";
|
import { gql, request } from "graphql-request";
|
||||||
|
import { list } from "subsrt-ts";
|
||||||
import { unzip } from "unzipit";
|
import { unzip } from "unzipit";
|
||||||
|
|
||||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
|
@ -115,3 +116,5 @@ export async function downloadSrt(legacySubId: string): Promise<string> {
|
||||||
const srtData = srtEntry.text();
|
const srtData = srtEntry.text();
|
||||||
return srtData;
|
return srtData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const subtitleTypeList = list().map((type) => `.${type}`);
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -12,6 +12,8 @@ interface Props {
|
||||||
padding?: string;
|
padding?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
download?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button(props: Props) {
|
export function Button(props: Props) {
|
||||||
|
@ -25,13 +27,23 @@ export function Button(props: Props) {
|
||||||
colorClasses =
|
colorClasses =
|
||||||
"bg-video-buttons-cancel hover:bg-video-buttons-cancelHover transition-colors duration-100 text-white";
|
"bg-video-buttons-cancel hover:bg-video-buttons-cancelHover transition-colors duration-100 text-white";
|
||||||
|
|
||||||
const classes = classNames(
|
let classes = classNames(
|
||||||
"cursor-pointer inline-flex items-center justify-center rounded-lg font-medium transition-[transform,background-color] duration-100 active:scale-105 md:px-8",
|
"cursor-pointer inline-flex items-center justify-center rounded-lg font-medium transition-[transform,background-color] duration-100 active:scale-105 md:px-8",
|
||||||
props.padding ?? "px-4 py-3",
|
props.padding ?? "px-4 py-3",
|
||||||
props.className,
|
props.className,
|
||||||
colorClasses
|
colorClasses,
|
||||||
|
props.disabled ? "cursor-not-allowed bg-opacity-60 text-opacity-60" : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (props.disabled)
|
||||||
|
classes = classes
|
||||||
|
.split(" ")
|
||||||
|
.filter(
|
||||||
|
(className) =>
|
||||||
|
!className.startsWith("hover:") && !className.startsWith("active:")
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{props.icon ? (
|
{props.icon ? (
|
||||||
|
@ -47,9 +59,18 @@ export function Button(props: Props) {
|
||||||
history.push(href);
|
history.push(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.href && props.href.startsWith("https://"))
|
if (
|
||||||
|
props.href &&
|
||||||
|
(props.href.startsWith("https://") || props.href?.startsWith("data:"))
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<a className={classes} href={props.href} target="_blank" rel="noreferrer">
|
<a
|
||||||
|
className={classes}
|
||||||
|
href={props.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={props.download}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
9
src/components/layout/Box.tsx
Normal file
9
src/components/layout/Box.tsx
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Box(props: { children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-video-scraping-card rounded-xl p-8">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -270,6 +270,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
|
||||||
onClick={() => updateStyling({ color: v })}
|
onClick={() => updateStyling({ color: v })}
|
||||||
color={v}
|
color={v}
|
||||||
active={styling.color === v}
|
active={styling.color === v}
|
||||||
|
key={v}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode, useRef, useState } from "react";
|
||||||
import { useAsync, useAsyncFn } from "react-use";
|
import { useAsync, useAsyncFn } from "react-use";
|
||||||
|
import { convert } from "subsrt-ts";
|
||||||
|
|
||||||
import { SubtitleSearchItem, languageIdToName } from "@/backend/helpers/subs";
|
import {
|
||||||
|
SubtitleSearchItem,
|
||||||
|
languageIdToName,
|
||||||
|
subtitleTypeList,
|
||||||
|
} from "@/backend/helpers/subs";
|
||||||
import { FlagIcon } from "@/components/FlagIcon";
|
import { FlagIcon } from "@/components/FlagIcon";
|
||||||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||||
|
@ -86,6 +91,41 @@ function searchSubs(
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CustomCaptionOption() {
|
||||||
|
const lang = usePlayerStore((s) => s.caption.selected?.language);
|
||||||
|
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CaptionOption
|
||||||
|
selected={lang === "custom"}
|
||||||
|
onClick={() => fileInput.current?.click()}
|
||||||
|
>
|
||||||
|
Upload captions
|
||||||
|
<input
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInput}
|
||||||
|
accept={subtitleTypeList.join(",")}
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.files) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", (event) => {
|
||||||
|
if (!event.target || typeof event.target.result !== "string")
|
||||||
|
return;
|
||||||
|
const converted = convert(event.target.result, "srt");
|
||||||
|
setCaption({
|
||||||
|
language: "custom",
|
||||||
|
srtData: converted,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
reader.readAsText(e.target.files[0], "utf-8");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CaptionOption>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO on initialize, download captions
|
// TODO on initialize, download captions
|
||||||
// TODO fix language names, some are unknown
|
// TODO fix language names, some are unknown
|
||||||
// TODO delay setting for captions
|
// TODO delay setting for captions
|
||||||
|
@ -177,6 +217,7 @@ export function CaptionsView({ id }: { id: string }) {
|
||||||
<CaptionOption onClick={() => disable()} selected={!lang}>
|
<CaptionOption onClick={() => disable()} selected={!lang}>
|
||||||
Off
|
Off
|
||||||
</CaptionOption>
|
</CaptionOption>
|
||||||
|
<CustomCaptionOption />
|
||||||
{content}
|
{content}
|
||||||
</Menu.ScrollToActiveSection>
|
</Menu.ScrollToActiveSection>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Button } from "@/components/Button";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { OverlayPage } from "@/components/overlays/OverlayPage";
|
import { OverlayPage } from "@/components/overlays/OverlayPage";
|
||||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||||
|
import { convertSubtitlesToDataurl } from "@/components/player/utils/captions";
|
||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
||||||
|
@ -22,6 +23,13 @@ export function DownloadView({ id }: { id: string }) {
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
const downloadUrl = useDownloadLink();
|
const downloadUrl = useDownloadLink();
|
||||||
|
|
||||||
|
const selectedCaption = usePlayerStore((s) => s.caption?.selected);
|
||||||
|
const subtitleUrl = selectedCaption
|
||||||
|
? convertSubtitlesToDataurl(selectedCaption?.srtData)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
console.log(subtitleUrl);
|
||||||
|
|
||||||
if (!downloadUrl) return null;
|
if (!downloadUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -30,7 +38,7 @@ export function DownloadView({ id }: { id: string }) {
|
||||||
Download
|
Download
|
||||||
</Menu.BackLink>
|
</Menu.BackLink>
|
||||||
<Menu.Section>
|
<Menu.Section>
|
||||||
<div className="mt-3">
|
<div>
|
||||||
<Menu.ChevronLink onClick={() => router.navigate("/download/pc")}>
|
<Menu.ChevronLink onClick={() => router.navigate("/download/pc")}>
|
||||||
Downloading on PC
|
Downloading on PC
|
||||||
</Menu.ChevronLink>
|
</Menu.ChevronLink>
|
||||||
|
@ -45,13 +53,22 @@ export function DownloadView({ id }: { id: string }) {
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Paragraph>
|
<Menu.Paragraph marginClass="my-6">
|
||||||
Downloads are taken directly from the provider. movie-web does not
|
Downloads are taken directly from the provider. movie-web does not
|
||||||
have control over how the downloads are provided.
|
have control over how the downloads are provided.
|
||||||
</Menu.Paragraph>
|
</Menu.Paragraph>
|
||||||
|
|
||||||
<Button className="w-full" href={downloadUrl} theme="purple">
|
<Button className="w-full" href={downloadUrl} theme="purple">
|
||||||
Download
|
Download video
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full mt-2"
|
||||||
|
href={subtitleUrl ?? undefined}
|
||||||
|
disabled={!subtitleUrl}
|
||||||
|
theme="secondary"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
Download current caption
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Menu.Section>
|
</Menu.Section>
|
||||||
|
@ -148,7 +165,7 @@ function IOSExplanationView({ id }: { id: string }) {
|
||||||
export function DownloadRoutes({ id }: { id: string }) {
|
export function DownloadRoutes({ id }: { id: string }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OverlayPage id={id} path="/download" width={343} height={440}>
|
<OverlayPage id={id} path="/download" width={343} height={490}>
|
||||||
<Menu.CardWithScrollable>
|
<Menu.CardWithScrollable>
|
||||||
<DownloadView id={id} />
|
<DownloadView id={id} />
|
||||||
</Menu.CardWithScrollable>
|
</Menu.CardWithScrollable>
|
||||||
|
|
|
@ -49,8 +49,11 @@ export function FieldTitle(props: { children: React.ReactNode }) {
|
||||||
return <p className="font-medium">{props.children}</p>;
|
return <p className="font-medium">{props.children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Paragraph(props: { children: React.ReactNode }) {
|
export function Paragraph(props: {
|
||||||
return <p className="my-3">{props.children}</p>;
|
children: React.ReactNode;
|
||||||
|
marginClass?: string;
|
||||||
|
}) {
|
||||||
|
return <p className={props.marginClass ?? "my-3"}>{props.children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Highlight(props: { children: React.ReactNode }) {
|
export function Highlight(props: { children: React.ReactNode }) {
|
||||||
|
|
|
@ -1,3 +1,17 @@
|
||||||
export function Paragraph(props: { children: React.ReactNode }) {
|
import classNames from "classnames";
|
||||||
return <p className="text-errors-type-secondary mt-6">{props.children}</p>;
|
|
||||||
|
export function Paragraph(props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
marginClass?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={classNames(
|
||||||
|
"text-errors-type-secondary",
|
||||||
|
props.marginClass ?? "mt-6"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
17
src/pages/admin/AdminPage.tsx
Normal file
17
src/pages/admin/AdminPage.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||||
|
import { Heading1, Paragraph } from "@/components/utils/Text";
|
||||||
|
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||||
|
import { WorkerTestPart } from "@/pages/parts/admin/WorkerTestPart";
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
return (
|
||||||
|
<SubPageLayout>
|
||||||
|
<ThinContainer>
|
||||||
|
<Heading1>Admin tools</Heading1>
|
||||||
|
<Paragraph>Useful tools to test out your current deployment</Paragraph>
|
||||||
|
|
||||||
|
<WorkerTestPart />
|
||||||
|
</ThinContainer>
|
||||||
|
</SubPageLayout>
|
||||||
|
);
|
||||||
|
}
|
113
src/pages/parts/admin/WorkerTestPart.tsx
Normal file
113
src/pages/parts/admin/WorkerTestPart.tsx
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { f } from "ofetch/dist/shared/ofetch.441891d5";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useAsyncFn } from "react-use";
|
||||||
|
|
||||||
|
import { mwFetch } from "@/backend/helpers/fetch";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
import { Box } from "@/components/layout/Box";
|
||||||
|
import { Divider } from "@/components/player/internals/ContextMenu/Misc";
|
||||||
|
import { Heading2 } from "@/components/utils/Text";
|
||||||
|
import { conf } from "@/setup/config";
|
||||||
|
|
||||||
|
export function WorkerItem(props: {
|
||||||
|
name: string;
|
||||||
|
errored?: boolean;
|
||||||
|
success?: boolean;
|
||||||
|
errorText?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex mb-2">
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
props.errored
|
||||||
|
? Icons.WARNING
|
||||||
|
: props.success
|
||||||
|
? Icons.CIRCLE_CHECK
|
||||||
|
: Icons.EYE_SLASH
|
||||||
|
}
|
||||||
|
className={classNames({
|
||||||
|
"text-xl mr-2 mt-0.5": true,
|
||||||
|
"text-video-scraping-error": props.errored,
|
||||||
|
"text-video-scraping-noresult": !props.errored && !props.success,
|
||||||
|
"text-video-scraping-success": props.success,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-white font-bold">{props.name}</p>
|
||||||
|
{props.errorText ? <p>{props.errorText}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkerTestPart() {
|
||||||
|
const workerList = useMemo(() => {
|
||||||
|
return conf().PROXY_URLS.map((v, ind) => ({
|
||||||
|
id: ind.toString(),
|
||||||
|
url: v,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
const [workerState, setWorkerState] = useState<
|
||||||
|
{ id: string; status: "error" | "success"; error?: Error }[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const runTests = useAsyncFn(async () => {
|
||||||
|
function updateWorker(id: string, data: (typeof workerState)[number]) {
|
||||||
|
setWorkerState((s) => {
|
||||||
|
return [...s.filter((v) => v.id !== id), data];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setWorkerState([]);
|
||||||
|
for (const worker of workerList) {
|
||||||
|
try {
|
||||||
|
await mwFetch(worker.url, {
|
||||||
|
query: {
|
||||||
|
destination: "https://postman-echo.com/get",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updateWorker(worker.id, {
|
||||||
|
id: worker.id,
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
updateWorker(worker.id, {
|
||||||
|
id: worker.id,
|
||||||
|
status: "error",
|
||||||
|
error: err as Error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [workerList, setWorkerState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Heading2 className="mb-0 mt-12">Worker tests</Heading2>
|
||||||
|
<p className="mb-8 mt-2">15 workers registered</p>
|
||||||
|
<Box>
|
||||||
|
{workerList.map((v, i) => {
|
||||||
|
const s = workerState.find((segment) => segment.id);
|
||||||
|
const name = `Worker ${i + 1}`;
|
||||||
|
if (!s) return <WorkerItem name={name} key={v.id} />;
|
||||||
|
if (s.status === "error")
|
||||||
|
return (
|
||||||
|
<WorkerItem
|
||||||
|
name={name}
|
||||||
|
errored
|
||||||
|
key={v.id}
|
||||||
|
errorText={s.error?.toString()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (s.status === "success")
|
||||||
|
return <WorkerItem name={name} success key={v.id} />;
|
||||||
|
return <WorkerItem name={name} key={v.id} />;
|
||||||
|
})}
|
||||||
|
<Divider />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button theme="purple">Run tests</Button>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
|
||||||
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
|
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
|
||||||
import { useOnlineListener } from "@/hooks/usePing";
|
import { useOnlineListener } from "@/hooks/usePing";
|
||||||
import { AboutPage } from "@/pages/About";
|
import { AboutPage } from "@/pages/About";
|
||||||
|
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||||
import { DmcaPage } from "@/pages/Dmca";
|
import { DmcaPage } from "@/pages/Dmca";
|
||||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||||
import { HomePage } from "@/pages/HomePage";
|
import { HomePage } from "@/pages/HomePage";
|
||||||
|
@ -102,6 +103,9 @@ function App() {
|
||||||
<Route exact path="/faq" component={AboutPage} />
|
<Route exact path="/faq" component={AboutPage} />
|
||||||
<Route exact path="/dmca" component={DmcaPage} />
|
<Route exact path="/dmca" component={DmcaPage} />
|
||||||
|
|
||||||
|
{/* admin routes */}
|
||||||
|
<Route exact path="/admin" component={AdminPage} />
|
||||||
|
|
||||||
{/* other */}
|
{/* other */}
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
|
|
Loading…
Reference in a new issue