mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-25 07:51:51 +01:00
episode ids , shorter debounce and flixHQ provider
This commit is contained in:
parent
5a01a68ce4
commit
f472f04735
@ -6,6 +6,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||||
"@headlessui/react": "^1.5.0",
|
"@headlessui/react": "^1.5.0",
|
||||||
|
"@types/react-helmet": "^6.1.6",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
"fscreen": "^1.2.0",
|
"fscreen": "^1.2.0",
|
||||||
"fuse.js": "^6.4.6",
|
"fuse.js": "^6.4.6",
|
||||||
@ -19,6 +20,7 @@
|
|||||||
"ofetch": "^1.0.0",
|
"ofetch": "^1.0.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
"react-i18next": "^12.1.1",
|
"react-i18next": "^12.1.1",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-stickynode": "^4.1.0",
|
"react-stickynode": "^4.1.0",
|
||||||
|
@ -21,7 +21,22 @@ export function mwFetch<T>(url: string, ops: P<T>[1]): R<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function proxiedFetch<T>(url: string, ops: P<T>[1]): R<T> {
|
export function proxiedFetch<T>(url: string, ops: P<T>[1]): R<T> {
|
||||||
const parsedUrl = new URL(url);
|
let combinedUrl = ops?.baseURL ?? "";
|
||||||
|
if (
|
||||||
|
combinedUrl.length > 0 &&
|
||||||
|
combinedUrl.endsWith("/") &&
|
||||||
|
url.startsWith("/")
|
||||||
|
)
|
||||||
|
combinedUrl += url.slice(1);
|
||||||
|
else if (
|
||||||
|
combinedUrl.length > 0 &&
|
||||||
|
!combinedUrl.endsWith("/") &&
|
||||||
|
!url.startsWith("/")
|
||||||
|
)
|
||||||
|
combinedUrl += `/${url}`;
|
||||||
|
else combinedUrl += url;
|
||||||
|
|
||||||
|
const parsedUrl = new URL(combinedUrl);
|
||||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||||
parsedUrl.searchParams.set(k, v);
|
parsedUrl.searchParams.set(k, v);
|
||||||
});
|
});
|
||||||
|
@ -20,8 +20,8 @@ type MWProviderTypeSpecific =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: MWMediaType.SERIES;
|
type: MWMediaType.SERIES;
|
||||||
episode: number;
|
episode: string;
|
||||||
season: number;
|
season: string;
|
||||||
};
|
};
|
||||||
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
|
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;
|
||||||
|
|
||||||
|
@ -31,8 +31,8 @@ type MWProviderRunContextTypeSpecific =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: MWMediaType.SERIES;
|
type: MWMediaType.SERIES;
|
||||||
episode: number;
|
episode: string;
|
||||||
season: number;
|
season: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MWProviderRunContext = MWProviderRunContextBase &
|
export type MWProviderRunContext = MWProviderRunContextBase &
|
||||||
|
@ -2,6 +2,7 @@ import { initializeScraperStore } from "./helpers/register";
|
|||||||
|
|
||||||
// providers
|
// providers
|
||||||
import "./providers/gdriveplayer";
|
import "./providers/gdriveplayer";
|
||||||
|
import "./providers/flixhq";
|
||||||
|
|
||||||
// embeds
|
// embeds
|
||||||
// -- nothing here yet
|
// -- nothing here yet
|
||||||
|
@ -3,6 +3,7 @@ import { makeUrl, mwFetch } from "../helpers/fetch";
|
|||||||
import {
|
import {
|
||||||
formatJWMeta,
|
formatJWMeta,
|
||||||
JWMediaResult,
|
JWMediaResult,
|
||||||
|
JWSeasonMetaResult,
|
||||||
JW_API_BASE,
|
JW_API_BASE,
|
||||||
mediaTypeToJW,
|
mediaTypeToJW,
|
||||||
} from "./justwatch";
|
} from "./justwatch";
|
||||||
@ -33,7 +34,8 @@ export interface DetailedMeta {
|
|||||||
|
|
||||||
export async function getMetaFromId(
|
export async function getMetaFromId(
|
||||||
type: MWMediaType,
|
type: MWMediaType,
|
||||||
id: string
|
id: string,
|
||||||
|
seasonId?: string
|
||||||
): Promise<DetailedMeta | null> {
|
): Promise<DetailedMeta | null> {
|
||||||
const queryType = mediaTypeToJW(type);
|
const queryType = mediaTypeToJW(type);
|
||||||
|
|
||||||
@ -61,8 +63,17 @@ export async function getMetaFromId(
|
|||||||
|
|
||||||
if (!imdbId || !tmdbId) throw new Error("not enough info");
|
if (!imdbId || !tmdbId) throw new Error("not enough info");
|
||||||
|
|
||||||
|
let seasonData: JWSeasonMetaResult | undefined;
|
||||||
|
if (data.object_type === "show") {
|
||||||
|
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
|
||||||
|
const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", {
|
||||||
|
id: seasonToScrape,
|
||||||
|
});
|
||||||
|
seasonData = await mwFetch<any>(url, { baseURL: JW_API_BASE });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: formatJWMeta(data),
|
meta: formatJWMeta(data, seasonData),
|
||||||
imdbId,
|
imdbId,
|
||||||
tmdbId,
|
tmdbId,
|
||||||
};
|
};
|
||||||
|
@ -1,10 +1,22 @@
|
|||||||
import { MWMediaType } from "./types";
|
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types";
|
||||||
|
|
||||||
export const JW_API_BASE = "https://apis.justwatch.com";
|
export const JW_API_BASE = "https://apis.justwatch.com";
|
||||||
export const JW_IMAGE_BASE = "https://images.justwatch.com";
|
export const JW_IMAGE_BASE = "https://images.justwatch.com";
|
||||||
|
|
||||||
export type JWContentTypes = "movie" | "show";
|
export type JWContentTypes = "movie" | "show";
|
||||||
|
|
||||||
|
export type JWSeasonShort = {
|
||||||
|
title: string;
|
||||||
|
id: number;
|
||||||
|
season_number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JWEpisodeShort = {
|
||||||
|
title: string;
|
||||||
|
id: number;
|
||||||
|
episode_number: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type JWMediaResult = {
|
export type JWMediaResult = {
|
||||||
title: string;
|
title: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
@ -12,6 +24,14 @@ export type JWMediaResult = {
|
|||||||
original_release_year: number;
|
original_release_year: number;
|
||||||
jw_entity_id: string;
|
jw_entity_id: string;
|
||||||
object_type: JWContentTypes;
|
object_type: JWContentTypes;
|
||||||
|
seasons?: JWSeasonShort[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JWSeasonMetaResult = {
|
||||||
|
title: string;
|
||||||
|
id: string;
|
||||||
|
season_number: number;
|
||||||
|
episodes: JWEpisodeShort[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
|
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
|
||||||
@ -26,8 +46,24 @@ export function JWMediaToMediaType(type: string): MWMediaType {
|
|||||||
throw new Error("unsupported type");
|
throw new Error("unsupported type");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatJWMeta(media: JWMediaResult) {
|
export function formatJWMeta(
|
||||||
|
media: JWMediaResult,
|
||||||
|
season?: JWSeasonMetaResult
|
||||||
|
): MWMediaMeta {
|
||||||
const type = JWMediaToMediaType(media.object_type);
|
const type = JWMediaToMediaType(media.object_type);
|
||||||
|
let seasons: undefined | MWSeasonMeta[];
|
||||||
|
if (type === MWMediaType.SERIES) {
|
||||||
|
seasons = media.seasons
|
||||||
|
?.sort((a, b) => a.season_number - b.season_number)
|
||||||
|
.map(
|
||||||
|
(v): MWSeasonMeta => ({
|
||||||
|
id: v.id.toString(),
|
||||||
|
number: v.season_number,
|
||||||
|
title: v.title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: media.title,
|
title: media.title,
|
||||||
id: media.id.toString(),
|
id: media.id.toString(),
|
||||||
@ -36,5 +72,41 @@ export function formatJWMeta(media: JWMediaResult) {
|
|||||||
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
|
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
|
||||||
: undefined,
|
: undefined,
|
||||||
type,
|
type,
|
||||||
|
seasons: seasons as any,
|
||||||
|
seasonData: season
|
||||||
|
? ({
|
||||||
|
id: season.id.toString(),
|
||||||
|
number: season.season_number,
|
||||||
|
title: season.title,
|
||||||
|
episodes: season.episodes
|
||||||
|
.sort((a, b) => a.episode_number - b.episode_number)
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.id.toString(),
|
||||||
|
number: v.episode_number,
|
||||||
|
title: v.title,
|
||||||
|
})),
|
||||||
|
} as any)
|
||||||
|
: (undefined as any),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JWMediaToId(media: MWMediaMeta): string {
|
||||||
|
return ["JW", mediaTypeToJW(media.type), media.id].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJWId(
|
||||||
|
paramId: string
|
||||||
|
): { id: string; type: MWMediaType } | null {
|
||||||
|
const [prefix, type, id] = paramId.split("-", 3);
|
||||||
|
if (prefix !== "JW") return null;
|
||||||
|
let mediaType;
|
||||||
|
try {
|
||||||
|
mediaType = JWMediaToMediaType(type);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: mediaType,
|
||||||
|
id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -4,14 +4,43 @@ export enum MWMediaType {
|
|||||||
ANIME = "anime",
|
ANIME = "anime",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MWMediaMeta = {
|
export type MWSeasonMeta = {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWSeasonWithEpisodeMeta = {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
episodes: {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MWMediaMetaBase = {
|
||||||
title: string;
|
title: string;
|
||||||
id: string;
|
id: string;
|
||||||
year: string;
|
year: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
type: MWMediaType;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MWMediaMetaSpecific =
|
||||||
|
| {
|
||||||
|
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||||
|
seasons: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: MWMediaType.SERIES;
|
||||||
|
seasons: MWSeasonMeta[];
|
||||||
|
seasonData: MWSeasonWithEpisodeMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
|
||||||
|
|
||||||
export interface MWQuery {
|
export interface MWQuery {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
type: MWMediaType;
|
type: MWMediaType;
|
||||||
|
@ -1,37 +1,63 @@
|
|||||||
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
import { registerProvider } from "../helpers/register";
|
import { registerProvider } from "../helpers/register";
|
||||||
|
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
|
||||||
const timeout = (time: number) =>
|
const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => resolve(), time);
|
|
||||||
});
|
|
||||||
|
|
||||||
registerProvider({
|
registerProvider({
|
||||||
id: "testprov",
|
id: "flixhq",
|
||||||
rank: 42,
|
displayName: "FlixHQ",
|
||||||
|
rank: 100,
|
||||||
type: [MWMediaType.MOVIE],
|
type: [MWMediaType.MOVIE],
|
||||||
disabled: true,
|
|
||||||
|
|
||||||
async scrape({ progress }) {
|
async scrape({ media, progress }) {
|
||||||
await timeout(1000);
|
// search for relevant item
|
||||||
|
const searchResults = await proxiedFetch<any>(
|
||||||
|
`/${encodeURIComponent(media.meta.title)}`,
|
||||||
|
{
|
||||||
|
baseURL: flixHqBase,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// TODO fuzzy match or normalize title before comparison
|
||||||
|
const foundItem = searchResults.results.find((v: any) => {
|
||||||
|
return v.title === media.meta.title && v.releaseDate === media.meta.year;
|
||||||
|
});
|
||||||
|
if (!foundItem) throw new Error("No watchable item found");
|
||||||
|
const flixId = foundItem.id;
|
||||||
|
|
||||||
|
// get media info
|
||||||
progress(25);
|
progress(25);
|
||||||
await timeout(1000);
|
const mediaInfo = await proxiedFetch<any>("/info", {
|
||||||
progress(50);
|
baseURL: flixHqBase,
|
||||||
await timeout(1000);
|
params: {
|
||||||
|
id: flixId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// get stream info from media
|
||||||
progress(75);
|
progress(75);
|
||||||
await timeout(1000);
|
const watchInfo = await proxiedFetch<any>("/watch", {
|
||||||
|
baseURL: flixHqBase,
|
||||||
|
params: {
|
||||||
|
episodeId: mediaInfo.episodes[0].id,
|
||||||
|
mediaId: flixId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// get best quality source
|
||||||
|
const source = watchInfo.sources.reduce((p: any, c: any) =>
|
||||||
|
c.quality > p.quality ? c : p
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
embeds: [
|
embeds: [],
|
||||||
// {
|
stream: {
|
||||||
// type: MWEmbedType.OPENLOAD,
|
streamUrl: source.url,
|
||||||
// url: "https://google.com",
|
quality: MWStreamQuality.QUNKNOWN,
|
||||||
// },
|
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||||
// {
|
captions: [],
|
||||||
// type: MWEmbedType.ANOTHER,
|
},
|
||||||
// url: "https://google.com",
|
|
||||||
// },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -22,7 +22,7 @@ export function EditButton(props: EditButtonProps) {
|
|||||||
>
|
>
|
||||||
<span ref={parent}>
|
<span ref={parent}>
|
||||||
{props.editing ? (
|
{props.editing ? (
|
||||||
<span className="mx-4">Stop editing</span>
|
<span className="mx-4 whitespace-nowrap">Stop editing</span>
|
||||||
) : (
|
) : (
|
||||||
<Icon icon={Icons.EDIT} />
|
<Icon icon={Icons.EDIT} />
|
||||||
)}
|
)}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { DotList } from "@/components/text/DotList";
|
import { DotList } from "@/components/text/DotList";
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { mediaTypeToJW } from "@/backend/metadata/justwatch";
|
import { JWMediaToId } from "@/backend/metadata/justwatch";
|
||||||
import { Icons } from "../Icon";
|
import { Icons } from "../Icon";
|
||||||
import { IconPatch } from "../buttons/IconPatch";
|
import { IconPatch } from "../buttons/IconPatch";
|
||||||
|
|
||||||
@ -107,9 +107,7 @@ export function MediaCard(props: MediaCardProps) {
|
|||||||
const canLink = props.linkable && !props.closable;
|
const canLink = props.linkable && !props.closable;
|
||||||
|
|
||||||
const link = canLink
|
const link = canLink
|
||||||
? `/media/${encodeURIComponent(
|
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
|
||||||
mediaTypeToJW(props.media.type)
|
|
||||||
)}-${encodeURIComponent(props.media.id)}`
|
|
||||||
: "#";
|
: "#";
|
||||||
|
|
||||||
if (!props.linkable) return <span>{content}</span>;
|
if (!props.linkable) return <span>{content}</span>;
|
||||||
|
@ -1,26 +1,46 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useVideoPlayerState } from "../VideoContext";
|
import { useVideoPlayerState } from "../VideoContext";
|
||||||
|
|
||||||
interface ShowControlProps {
|
interface ShowControlProps {
|
||||||
series?: {
|
series?: {
|
||||||
episode: number;
|
episodeId: string;
|
||||||
season: number;
|
seasonId: string;
|
||||||
};
|
};
|
||||||
title?: string;
|
onSelect?: (state: { episodeId?: string; seasonId?: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShowControl(props: ShowControlProps) {
|
export function ShowControl(props: ShowControlProps) {
|
||||||
const { videoState } = useVideoPlayerState();
|
const { videoState } = useVideoPlayerState();
|
||||||
|
const lastState = useRef<{
|
||||||
|
episodeId?: string;
|
||||||
|
seasonId?: string;
|
||||||
|
} | null>({
|
||||||
|
episodeId: props.series?.episodeId,
|
||||||
|
seasonId: props.series?.seasonId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
videoState.setShowData({
|
videoState.setShowData({
|
||||||
current: props.series,
|
current: props.series,
|
||||||
isSeries: !!props.series,
|
isSeries: !!props.series,
|
||||||
title: props.title,
|
|
||||||
});
|
});
|
||||||
// we only want it to run when props change, not when videoState changes
|
// we only want it to run when props change, not when videoState changes
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [props]);
|
}, [props]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentState = {
|
||||||
|
episodeId: videoState.seasonData.current?.episodeId,
|
||||||
|
seasonId: videoState.seasonData.current?.seasonId,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
currentState.episodeId !== lastState.current?.episodeId ||
|
||||||
|
currentState.seasonId !== lastState.current?.seasonId
|
||||||
|
) {
|
||||||
|
lastState.current = currentState;
|
||||||
|
props.onSelect?.(currentState);
|
||||||
|
}
|
||||||
|
}, [videoState, props]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -63,7 +63,7 @@ export function VolumeControl(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`linear -ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ${
|
className={`linear -ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ${
|
||||||
hoveredOnce ? "!w-24 opacity-100" : "w-4 opacity-0"
|
hoveredOnce || dragging ? "!w-24 opacity-100" : "w-4 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -13,11 +13,10 @@ import { getStoredVolume, setStoredVolume } from "./volumeStore";
|
|||||||
|
|
||||||
interface ShowData {
|
interface ShowData {
|
||||||
current?: {
|
current?: {
|
||||||
episode: number;
|
episodeId: string;
|
||||||
season: number;
|
seasonId: string;
|
||||||
};
|
};
|
||||||
isSeries: boolean;
|
isSeries: boolean;
|
||||||
title?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerControls {
|
export interface PlayerControls {
|
||||||
|
@ -26,10 +26,9 @@ export type PlayerState = {
|
|||||||
seasonData: {
|
seasonData: {
|
||||||
isSeries: boolean;
|
isSeries: boolean;
|
||||||
current?: {
|
current?: {
|
||||||
episode: number;
|
episodeId: string;
|
||||||
season: number;
|
seasonId: string;
|
||||||
};
|
};
|
||||||
title?: string;
|
|
||||||
};
|
};
|
||||||
error: null | {
|
error: null | {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -15,8 +15,8 @@ export interface ScrapeEventLog {
|
|||||||
export type SelectedMediaData =
|
export type SelectedMediaData =
|
||||||
| {
|
| {
|
||||||
type: MWMediaType.SERIES;
|
type: MWMediaType.SERIES;
|
||||||
episode: number;
|
episode: string;
|
||||||
season: number;
|
season: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
||||||
|
@ -42,7 +42,6 @@ if (key) {
|
|||||||
|
|
||||||
// TODO general todos:
|
// TODO general todos:
|
||||||
// - localize everything
|
// - localize everything
|
||||||
// - add titles to pages
|
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
@ -16,6 +16,11 @@ function App() {
|
|||||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route exact path="/media/:media" component={MediaView} />
|
<Route exact path="/media/:media" component={MediaView} />
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path="/media/:media/:season/:episode"
|
||||||
|
component={MediaView}
|
||||||
|
/>
|
||||||
<Route exact path="/search/:type/:query?" component={SearchView} />
|
<Route exact path="/search/:type/:query?" component={SearchView} />
|
||||||
<Route path="*" component={NotFoundPage} />
|
<Route path="*" component={NotFoundPage} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
@ -4,12 +4,16 @@ import { Link } from "@/components/text/Link";
|
|||||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||||
import { useGoBack } from "@/hooks/useGoBack";
|
import { useGoBack } from "@/hooks/useGoBack";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
export function MediaFetchErrorView() {
|
export function MediaFetchErrorView() {
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex-1">
|
<div className="h-screen flex-1">
|
||||||
|
<Helmet>
|
||||||
|
<title>Failed to load meta</title>
|
||||||
|
</Helmet>
|
||||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||||
<VideoPlayerHeader onClick={goBack} />
|
<VideoPlayerHeader onClick={goBack} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useHistory, useParams } from "react-router-dom";
|
||||||
|
import { Helmet } from "react-helmet";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
|
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
|
||||||
import { MWStream } from "@/backend/helpers/streams";
|
import { MWStream } from "@/backend/helpers/streams";
|
||||||
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
||||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
||||||
import { JWMediaToMediaType } from "@/backend/metadata/justwatch";
|
import { decodeJWId } from "@/backend/metadata/justwatch";
|
||||||
import { SourceControl } from "@/components/video/controls/SourceControl";
|
import { SourceControl } from "@/components/video/controls/SourceControl";
|
||||||
import { Loading } from "@/components/layout/Loading";
|
import { Loading } from "@/components/layout/Loading";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
@ -23,6 +24,9 @@ import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
|
|||||||
function MediaViewLoading(props: { onGoBack(): void }) {
|
function MediaViewLoading(props: { onGoBack(): void }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen items-center justify-center">
|
<div className="relative flex h-screen items-center justify-center">
|
||||||
|
<Helmet>
|
||||||
|
<title>Loading...</title>
|
||||||
|
</Helmet>
|
||||||
<div className="absolute inset-x-0 top-0 p-6">
|
<div className="absolute inset-x-0 top-0 p-6">
|
||||||
<VideoPlayerHeader onClick={props.onGoBack} />
|
<VideoPlayerHeader onClick={props.onGoBack} />
|
||||||
</div>
|
</div>
|
||||||
@ -51,6 +55,9 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen items-center justify-center">
|
<div className="relative flex h-screen items-center justify-center">
|
||||||
|
<Helmet>
|
||||||
|
<title>{props.meta.meta.title}</title>
|
||||||
|
</Helmet>
|
||||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
||||||
<VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
|
<VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
|
||||||
</div>
|
</div>
|
||||||
@ -85,6 +92,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
|||||||
interface MediaViewPlayerProps {
|
interface MediaViewPlayerProps {
|
||||||
meta: DetailedMeta;
|
meta: DetailedMeta;
|
||||||
stream: MWStream;
|
stream: MWStream;
|
||||||
|
selected: SelectedMediaData;
|
||||||
}
|
}
|
||||||
export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
@ -96,8 +104,13 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [props.stream]);
|
}, [props.stream]);
|
||||||
|
|
||||||
|
// TODO show episode title
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen">
|
<div className="h-screen w-screen">
|
||||||
|
<Helmet>
|
||||||
|
<title>{props.meta.meta.title}</title>
|
||||||
|
</Helmet>
|
||||||
<DecoratedVideoPlayer media={props.meta.meta} onGoBack={goBack} autoPlay>
|
<DecoratedVideoPlayer media={props.meta.meta} onGoBack={goBack} autoPlay>
|
||||||
<SourceControl
|
<SourceControl
|
||||||
source={props.stream.streamUrl}
|
source={props.stream.streamUrl}
|
||||||
@ -107,44 +120,71 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
|||||||
startAt={firstStartTime.current}
|
startAt={firstStartTime.current}
|
||||||
onProgress={updateProgress}
|
onProgress={updateProgress}
|
||||||
/>
|
/>
|
||||||
<ShowControl series={{ episode: 5, season: 2 }} title="hello world" />
|
{props.selected.type === MWMediaType.SERIES ? (
|
||||||
|
<ShowControl
|
||||||
|
series={{
|
||||||
|
seasonId: props.selected.season,
|
||||||
|
episodeId: props.selected.episode,
|
||||||
|
}}
|
||||||
|
onSelect={(d) => console.log("selected stuff", d)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</DecoratedVideoPlayer>
|
</DecoratedVideoPlayer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaView() {
|
export function MediaView() {
|
||||||
const params = useParams<{ media: string }>();
|
const params = useParams<{
|
||||||
|
media: string;
|
||||||
|
episode?: string;
|
||||||
|
season?: string;
|
||||||
|
}>();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
const [meta, setMeta] = useState<DetailedMeta | null>(null);
|
const [meta, setMeta] = useState<DetailedMeta | null>(null);
|
||||||
const [selected, setSelected] = useState<SelectedMediaData | null>(null);
|
const [selected, setSelected] = useState<SelectedMediaData | null>(null);
|
||||||
const [exec, loading, error] = useLoading(async (mediaParams: string) => {
|
const [exec, loading, error] = useLoading(
|
||||||
let type: MWMediaType;
|
async (mediaParams: string, seasonId?: string) => {
|
||||||
let id = "";
|
const data = decodeJWId(mediaParams);
|
||||||
try {
|
if (!data) return null;
|
||||||
const [t, i] = mediaParams.split("-", 2);
|
return getMetaFromId(data.type, data.id, seasonId);
|
||||||
type = JWMediaToMediaType(t);
|
|
||||||
id = i;
|
|
||||||
} catch (err) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
return getMetaFromId(type, id);
|
);
|
||||||
});
|
|
||||||
const [stream, setStream] = useState<MWStream | null>(null);
|
const [stream, setStream] = useState<MWStream | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
exec(params.media).then((v) => {
|
console.log("I am being ran");
|
||||||
|
exec(params.media, params.season).then((v) => {
|
||||||
setMeta(v ?? null);
|
setMeta(v ?? null);
|
||||||
if (v)
|
if (v) {
|
||||||
|
if (v.meta.type !== MWMediaType.SERIES) {
|
||||||
setSelected({
|
setSelected({
|
||||||
type: v.meta.type,
|
type: v.meta.type,
|
||||||
episode: 0 as any,
|
season: undefined,
|
||||||
season: 0 as any,
|
episode: undefined,
|
||||||
});
|
});
|
||||||
else setSelected(null);
|
} else {
|
||||||
|
const season = params.season ?? v.meta.seasonData.id;
|
||||||
|
const episode = params.episode ?? v.meta.seasonData.episodes[0].id;
|
||||||
|
setSelected({
|
||||||
|
type: MWMediaType.SERIES,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
});
|
});
|
||||||
}, [exec, params.media]);
|
if (season !== params.season || episode !== params.episode)
|
||||||
|
history.replace(
|
||||||
|
`/media/${encodeURIComponent(params.media)}/${encodeURIComponent(
|
||||||
|
season
|
||||||
|
)}/${encodeURIComponent(episode)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else setSelected(null);
|
||||||
|
});
|
||||||
|
// dont rerender when params changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [exec, history]);
|
||||||
|
|
||||||
if (loading) return <MediaViewLoading onGoBack={goBack} />;
|
if (loading) return <MediaViewLoading onGoBack={goBack} />;
|
||||||
if (error) return <MediaFetchErrorView />;
|
if (error) return <MediaFetchErrorView />;
|
||||||
@ -167,5 +207,5 @@ export function MediaView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// show stream once we have a stream
|
// show stream once we have a stream
|
||||||
return <MediaViewPlayer meta={meta} stream={stream} />;
|
return <MediaViewPlayer meta={meta} stream={stream} selected={selected} />;
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import { ArrowLink } from "@/components/text/ArrowLink";
|
|||||||
import { Title } from "@/components/text/Title";
|
import { Title } from "@/components/text/Title";
|
||||||
import { useGoBack } from "@/hooks/useGoBack";
|
import { useGoBack } from "@/hooks/useGoBack";
|
||||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||||
|
import { Helmet } from "react-helmet";
|
||||||
|
|
||||||
export function NotFoundWrapper(props: {
|
export function NotFoundWrapper(props: {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@ -16,6 +17,9 @@ export function NotFoundWrapper(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex-1">
|
<div className="h-screen flex-1">
|
||||||
|
<Helmet>
|
||||||
|
<title>Not found</title>
|
||||||
|
</Helmet>
|
||||||
{props.video ? (
|
{props.video ? (
|
||||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||||
<VideoPlayerHeader onClick={goBack} />
|
<VideoPlayerHeader onClick={goBack} />
|
||||||
|
@ -13,7 +13,7 @@ export function SearchResultsPartial({ search }: SearchResultsPartialProps) {
|
|||||||
const [searching, setSearching] = useState<boolean>(false);
|
const [searching, setSearching] = useState<boolean>(false);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const debouncedSearch = useDebounce<MWQuery>(search, 2000);
|
const debouncedSearch = useDebounce<MWQuery>(search, 500);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearching(search.searchQuery !== "");
|
setSearching(search.searchQuery !== "");
|
||||||
setLoading(search.searchQuery !== "");
|
setLoading(search.searchQuery !== "");
|
||||||
|
@ -7,6 +7,7 @@ import { SearchBarInput } from "@/components/SearchBar";
|
|||||||
import { Title } from "@/components/text/Title";
|
import { Title } from "@/components/text/Title";
|
||||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||||
import { WideContainer } from "@/components/layout/WideContainer";
|
import { WideContainer } from "@/components/layout/WideContainer";
|
||||||
|
import { Helmet } from "react-helmet";
|
||||||
import { SearchResultsPartial } from "./SearchResultsPartial";
|
import { SearchResultsPartial } from "./SearchResultsPartial";
|
||||||
|
|
||||||
export function SearchView() {
|
export function SearchView() {
|
||||||
@ -22,6 +23,9 @@ export function SearchView() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative z-10 mb-24">
|
<div className="relative z-10 mb-24">
|
||||||
|
<Helmet>
|
||||||
|
<title>movie-web</title>
|
||||||
|
</Helmet>
|
||||||
<Navigation bg={showBg} />
|
<Navigation bg={showBg} />
|
||||||
<ThinContainer>
|
<ThinContainer>
|
||||||
<div className="mt-44 space-y-16 text-center">
|
<div className="mt-44 space-y-16 text-center">
|
||||||
|
31
yarn.lock
31
yarn.lock
@ -321,6 +321,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "^17"
|
"@types/react" "^17"
|
||||||
|
|
||||||
|
"@types/react-helmet@^6.1.6":
|
||||||
|
"integrity" "sha512-ZKcoOdW/Tg+kiUbkFCBtvDw0k3nD4HJ/h/B9yWxN4uDO8OkRksWTO+EL+z/Qu3aHTeTll3Ro0Cc/8UhwBCMG5A=="
|
||||||
|
"resolved" "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.6.tgz"
|
||||||
|
"version" "6.1.6"
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react-router-dom@^5.3.3":
|
"@types/react-router-dom@^5.3.3":
|
||||||
"integrity" "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="
|
"integrity" "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="
|
||||||
"resolved" "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz"
|
"resolved" "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz"
|
||||||
@ -2881,7 +2888,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"read" "1"
|
"read" "1"
|
||||||
|
|
||||||
"prop-types@^15.6.0", "prop-types@^15.6.2", "prop-types@^15.8.1":
|
"prop-types@^15.6.0", "prop-types@^15.6.2", "prop-types@^15.7.2", "prop-types@^15.8.1":
|
||||||
"integrity" "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="
|
"integrity" "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="
|
||||||
"resolved" "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
|
"resolved" "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
|
||||||
"version" "15.8.1"
|
"version" "15.8.1"
|
||||||
@ -2924,6 +2931,21 @@
|
|||||||
"object-assign" "^4.1.1"
|
"object-assign" "^4.1.1"
|
||||||
"scheduler" "^0.20.2"
|
"scheduler" "^0.20.2"
|
||||||
|
|
||||||
|
"react-fast-compare@^3.1.1":
|
||||||
|
"integrity" "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
|
||||||
|
"resolved" "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz"
|
||||||
|
"version" "3.2.0"
|
||||||
|
|
||||||
|
"react-helmet@^6.1.0":
|
||||||
|
"integrity" "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw=="
|
||||||
|
"resolved" "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz"
|
||||||
|
"version" "6.1.0"
|
||||||
|
dependencies:
|
||||||
|
"object-assign" "^4.1.1"
|
||||||
|
"prop-types" "^15.7.2"
|
||||||
|
"react-fast-compare" "^3.1.1"
|
||||||
|
"react-side-effect" "^2.1.0"
|
||||||
|
|
||||||
"react-i18next@^12.1.1":
|
"react-i18next@^12.1.1":
|
||||||
"integrity" "sha512-mFdieOI0LDy84q3JuZU6Aou1DoWW2fhapcTGeBS8+vWSJuViuoCLQAMYSb0QoHhXS8B0WKUOPpx4cffAP7r/aA=="
|
"integrity" "sha512-mFdieOI0LDy84q3JuZU6Aou1DoWW2fhapcTGeBS8+vWSJuViuoCLQAMYSb0QoHhXS8B0WKUOPpx4cffAP7r/aA=="
|
||||||
"resolved" "https://registry.npmjs.org/react-i18next/-/react-i18next-12.1.1.tgz"
|
"resolved" "https://registry.npmjs.org/react-i18next/-/react-i18next-12.1.1.tgz"
|
||||||
@ -2965,6 +2987,11 @@
|
|||||||
"tiny-invariant" "^1.0.2"
|
"tiny-invariant" "^1.0.2"
|
||||||
"tiny-warning" "^1.0.0"
|
"tiny-warning" "^1.0.0"
|
||||||
|
|
||||||
|
"react-side-effect@^2.1.0":
|
||||||
|
"integrity" "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw=="
|
||||||
|
"resolved" "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz"
|
||||||
|
"version" "2.1.2"
|
||||||
|
|
||||||
"react-stickynode@^4.1.0":
|
"react-stickynode@^4.1.0":
|
||||||
"integrity" "sha512-zylWgfad75jLfh/gYIayDcDWIDwO4weZrsZqDpjZ/axhF06zRjdCWFBgUr33Pvv2+htKWqPSFksWTyB6aMQ1ZQ=="
|
"integrity" "sha512-zylWgfad75jLfh/gYIayDcDWIDwO4weZrsZqDpjZ/axhF06zRjdCWFBgUr33Pvv2+htKWqPSFksWTyB6aMQ1ZQ=="
|
||||||
"resolved" "https://registry.npmjs.org/react-stickynode/-/react-stickynode-4.1.0.tgz"
|
"resolved" "https://registry.npmjs.org/react-stickynode/-/react-stickynode-4.1.0.tgz"
|
||||||
@ -2986,7 +3013,7 @@
|
|||||||
"loose-envify" "^1.4.0"
|
"loose-envify" "^1.4.0"
|
||||||
"prop-types" "^15.6.2"
|
"prop-types" "^15.6.2"
|
||||||
|
|
||||||
"react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^17.0.2", "react@>= 16.8.0", "react@>=15", "react@>=16.6.0", "react@17.0.2":
|
"react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^16.3.0 || ^17.0.0 || ^18.0.0", "react@^17.0.2", "react@>= 16.8.0", "react@>=15", "react@>=16.3.0", "react@>=16.6.0", "react@17.0.2":
|
||||||
"integrity" "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA=="
|
"integrity" "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA=="
|
||||||
"resolved" "https://registry.npmjs.org/react/-/react-17.0.2.tgz"
|
"resolved" "https://registry.npmjs.org/react/-/react-17.0.2.tgz"
|
||||||
"version" "17.0.2"
|
"version" "17.0.2"
|
||||||
|
Loading…
Reference in New Issue
Block a user