diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 08c26e90..b6d90ffa 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -1,4 +1,4 @@ -import { GetProviderFromId, MWMedia, MWMediaType } from "scrapers"; +import { getProviderFromId, MWMedia, MWMediaType } from "scrapers"; import { Link } from "react-router-dom"; import { Icon, Icons } from "components/Icon"; @@ -32,7 +32,7 @@ function MediaCardContent({ linkable, watchedPercentage, }: MediaCardProps) { - const provider = GetProviderFromId(media.providerId); + const provider = getProviderFromId(media.providerId); if (!provider) { return null; @@ -62,7 +62,9 @@ function MediaCardContent({ {/* card content */}

{media.title}

- +
{/* hoverable chevron */} @@ -79,9 +81,8 @@ function MediaCardContent({ } export function MediaCard(props: MediaCardProps) { - const provider = GetProviderFromId(props.media.providerId); let link = "movie"; - if (provider?.type === MWMediaType.SERIES) link = "series"; + if (props.media.mediaType === MWMediaType.MOVIE) link = "series"; const content = ; diff --git a/src/components/media/WatchedMediaCard.tsx b/src/components/media/WatchedMediaCard.tsx index f1bed10d..e3019ff7 100644 --- a/src/components/media/WatchedMediaCard.tsx +++ b/src/components/media/WatchedMediaCard.tsx @@ -6,5 +6,5 @@ export interface WatchedMediaCardProps { } export function WatchedMediaCard(props: WatchedMediaCardProps) { - return ; + return ; } diff --git a/src/hooks/useLoading.ts b/src/hooks/useLoading.ts new file mode 100644 index 00000000..cd87dafe --- /dev/null +++ b/src/hooks/useLoading.ts @@ -0,0 +1,39 @@ +import React, { useState } from "react"; + +export function useLoading Promise>( + action: T +) { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(undefined); + let isMounted = true; + + React.useEffect(() => { + isMounted = true; + return () => { + isMounted = false; + }; + }, []); + + const doAction = async (...args: Parameters) => { + setLoading(true); + setSuccess(false); + setError(undefined); + return new Promise((resolve) => { + action(...args) + .then((v) => { + if (!isMounted) return resolve(undefined); + setSuccess(true); + resolve(v); + }) + .catch((err) => { + if (isMounted) { + setError(err); + setSuccess(false); + } + resolve(undefined); + }); + }).finally(() => isMounted && setLoading(false)); + }; + return [doAction, loading, error, success]; +} diff --git a/src/mw_constants.ts b/src/mw_constants.ts new file mode 100644 index 00000000..2274df2f --- /dev/null +++ b/src/mw_constants.ts @@ -0,0 +1,2 @@ +export const CORS_PROXY_URL = + "https://proxy-1.movie-web.workers.dev/?destination="; diff --git a/src/scrapers/README.md b/src/scrapers/README.md deleted file mode 100644 index 0684eef4..00000000 --- a/src/scrapers/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# about scrapers - -TODO - put stuff here later diff --git a/src/scrapers/index.ts b/src/scrapers/index.ts index cf58c1e0..5eb00845 100644 --- a/src/scrapers/index.ts +++ b/src/scrapers/index.ts @@ -1,21 +1,58 @@ -import { theFlixMovieScraper } from "./list/theflixmovie"; -import { theFlixSeriesScraper } from "./list/theflixseries"; -import { MWMediaProvider, MWQuery } from "./types"; +import { theFlixScraper } from "./list/theflix"; +import { MWMedia, MWMediaType, MWPortableMedia, MWQuery } from "./types"; +import { MWWrappedMediaProvider, WrapProvider } from "./wrapper"; export * from "./types"; -const mediaProvidersUnchecked: MWMediaProvider[] = [ - theFlixMovieScraper, - theFlixSeriesScraper, -] -export const mediaProviders: MWMediaProvider[] = mediaProvidersUnchecked.filter(v=>v.enabled); +const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [ + WrapProvider(theFlixScraper), +]; +export const mediaProviders: MWWrappedMediaProvider[] = + mediaProvidersUnchecked.filter((v) => v.enabled); -export async function SearchProviders(query: MWQuery) { - const allQueries = mediaProviders.map(provider => provider.searchForMedia(query)); +/* + ** Fetch all enabled providers for a specific type + */ +export function GetProvidersForType(type: MWMediaType) { + return mediaProviders.filter((v) => v.type.includes(type)); +} + +/* + ** Call search on all providers that matches query type + */ +export async function SearchProviders(query: MWQuery): Promise { + const allQueries = GetProvidersForType(query.type).map((provider) => + provider.searchForMedia(query) + ); const allResults = await Promise.all(allQueries); - - return allResults.flatMap(results => results); + return allResults.flatMap((results) => results); } -export function GetProviderFromId(id: string) { - return mediaProviders.find(v=>v.id===id); +/* + ** Get a provider by a id + */ +export function getProviderFromId(id: string) { + return mediaProviders.find((v) => v.id === id); +} + +/* + ** Turn media object into a portable media object + */ +export function convertMediaToPortable(media: MWMedia): MWPortableMedia { + return { + mediaId: media.mediaId, + providerId: media.providerId, + mediaType: media.mediaType, + episode: media.episode, + season: media.season, + }; +} + +/* + ** Turn portable media into media object + */ +export async function convertPortableToMedia( + portable: MWPortableMedia +): Promise { + const provider = getProviderFromId(portable.providerId); + return await provider?.getMediaFromPortable(portable); } diff --git a/src/scrapers/list/theflix/index.ts b/src/scrapers/list/theflix/index.ts new file mode 100644 index 00000000..b262041e --- /dev/null +++ b/src/scrapers/list/theflix/index.ts @@ -0,0 +1,46 @@ +import { + MWMediaProvider, + MWMediaType, + MWPortableMedia, + MWQuery, +} from "scrapers/types"; + +import { + searchTheFlix, + getDataFromSearch, + turnDataIntoMedia, +} from "scrapers/list/theflix/search"; + +import { getDataFromPortableSearch } from "scrapers/list/theflix/portableToMedia"; +import { MWProviderMediaResult } from "scrapers"; + +export const theFlixScraper: MWMediaProvider = { + id: "theflix", + enabled: true, + type: [MWMediaType.MOVIE, MWMediaType.SERIES], + displayName: "theflix", + + async getMediaFromPortable( + media: MWPortableMedia + ): Promise { + const data: any = await getDataFromPortableSearch(media); + + return { + ...media, + year: new Date(data.releaseDate).getFullYear().toString(), + title: data.name, + }; + }, + + async searchForMedia(query: MWQuery): Promise { + const searchRes = await searchTheFlix(query); + const searchData = await getDataFromSearch(searchRes, 10); + + const results: MWProviderMediaResult[] = []; + for (let item of searchData) { + results.push(turnDataIntoMedia(item)); + } + + return results; + }, +}; diff --git a/src/scrapers/list/theflix/portableToMedia.ts b/src/scrapers/list/theflix/portableToMedia.ts new file mode 100644 index 00000000..935332d1 --- /dev/null +++ b/src/scrapers/list/theflix/portableToMedia.ts @@ -0,0 +1,35 @@ +import { CORS_PROXY_URL } from "mw_constants"; +import { MWMediaType, MWPortableMedia } from "scrapers/types"; + +const getTheFlixUrl = (media: MWPortableMedia, params?: URLSearchParams) => { + if (media.mediaType === MWMediaType.MOVIE) { + return `https://theflix.to/movie/${media.mediaId}?${params}`; + } else if (media.mediaType === MWMediaType.SERIES) { + return `https://theflix.to/tv-show/${media.mediaId}/season-${media.season}/episode-${media.episode}`; + } + + return ""; +}; + +export async function getDataFromPortableSearch( + media: MWPortableMedia +): Promise { + const params = new URLSearchParams(); + params.append("movieInfo", media.mediaId); + + const res = await fetch(CORS_PROXY_URL + getTheFlixUrl(media, params)).then( + (d) => d.text() + ); + + const node: Element = Array.from( + new DOMParser() + .parseFromString(res, "text/html") + .querySelectorAll(`script[id="__NEXT_DATA__"]`) + )[0]; + + if (media.mediaType === MWMediaType.MOVIE) { + return JSON.parse(node.innerHTML).props.pageProps.movie; + } else if (media.mediaType === MWMediaType.SERIES) { + return JSON.parse(node.innerHTML).props.pageProps.selectedTv; + } +} diff --git a/src/scrapers/list/theflix/search.ts b/src/scrapers/list/theflix/search.ts new file mode 100644 index 00000000..6754051e --- /dev/null +++ b/src/scrapers/list/theflix/search.ts @@ -0,0 +1,44 @@ +import { CORS_PROXY_URL } from "mw_constants"; +import { MWMediaType, MWProviderMediaResult, MWQuery } from "scrapers"; + +const getTheFlixUrl = (type: "tv-shows" | "movies", params: URLSearchParams) => + `https://theflix.to/${type}/trending?${params}`; + +export async function searchTheFlix(query: MWQuery): Promise { + const params = new URLSearchParams(); + params.append("search", query.searchQuery); + return await fetch( + CORS_PROXY_URL + + getTheFlixUrl( + query.type === MWMediaType.MOVIE ? "movies" : "tv-shows", + params + ) + ).then((d) => d.text()); +} + +export function getDataFromSearch(page: string, limit: number = 10): any[] { + const node: Element = Array.from( + new DOMParser() + .parseFromString(page, "text/html") + .querySelectorAll(`script[id="__NEXT_DATA__"]`) + )[0]; + const data = JSON.parse(node.innerHTML); + return data.props.pageProps.mainList.docs + .filter((d: any) => d.available) + .slice(0, limit); +} + +export function turnDataIntoMedia(data: any): MWProviderMediaResult { + return { + mediaId: + data.id + + "-" + + data.name + .replace(/[^a-z0-9]+|\s+/gim, " ") + .trim() + .replace(/\s+/g, "-") + .toLowerCase(), + title: data.name, + year: new Date(data.releaseDate).getFullYear().toString(), + }; +} diff --git a/src/scrapers/list/theflixmovie/index.ts b/src/scrapers/list/theflixmovie/index.ts deleted file mode 100644 index feb92053..00000000 --- a/src/scrapers/list/theflixmovie/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MWMedia, MWMediaProvider, MWMediaType, MWPortableMedia, MWQuery } from "scrapers/types"; - -export const theFlixMovieScraper: MWMediaProvider = { - id: "theflixmovie", - enabled: true, - type: MWMediaType.MOVIE, - displayName: "TheFlix", - - async getMediaFromPortable(media: MWPortableMedia): Promise { - return { - ...media, - title: "title is here" - } - }, - - async searchForMedia(query: MWQuery): Promise { - return [{ - mediaId: "a", - providerId: this.id, - title: `movie testing in progress`, - }]; - }, -} diff --git a/src/scrapers/list/theflixseries/index.ts b/src/scrapers/list/theflixseries/index.ts deleted file mode 100644 index ce5a00c1..00000000 --- a/src/scrapers/list/theflixseries/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MWMedia, MWMediaProvider, MWMediaType, MWPortableMedia, MWQuery } from "scrapers/types"; - -export const theFlixSeriesScraper: MWMediaProvider = { - id: "theflixseries", - enabled: true, - type: MWMediaType.SERIES, - displayName: "TheFlix", - - async getMediaFromPortable(media: MWPortableMedia): Promise { - return { - ...media, - title: "title here" - } - }, - - async searchForMedia(query: MWQuery): Promise { - return [{ - mediaId: "b", - providerId: this.id, - title: `series test`, - }]; - }, -} diff --git a/src/scrapers/types.ts b/src/scrapers/types.ts index ceb44885..e362f856 100644 --- a/src/scrapers/types.ts +++ b/src/scrapers/types.ts @@ -5,25 +5,31 @@ export enum MWMediaType { } export interface MWPortableMedia { - mediaId: string, - providerId: string, + mediaId: string; + mediaType: MWMediaType; + providerId: string; + season?: number; + episode?: number; } export interface MWMedia extends MWPortableMedia { - title: string, + title: string; + year: string; } +export type MWProviderMediaResult = Omit; + export interface MWQuery { - searchQuery: string, - type: MWMediaType, + searchQuery: string; + type: MWMediaType; } export interface MWMediaProvider { - id: string, // id of provider, must be unique - enabled: boolean, - type: MWMediaType, - displayName: string, + id: string; // id of provider, must be unique + enabled: boolean; + type: MWMediaType[]; + displayName: string; - getMediaFromPortable(media: MWPortableMedia): Promise, - searchForMedia(query: MWQuery): Promise, + getMediaFromPortable(media: MWPortableMedia): Promise; + searchForMedia(query: MWQuery): Promise; } diff --git a/src/utils/storage.ts b/src/utils/storage.ts index e662e74b..add77d96 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -4,7 +4,6 @@ also type safety is important, this is all spaghetti with "any" everywhere */ - function buildStoreObject(d: any) { const data: any = { versions: d.versions, @@ -23,7 +22,7 @@ function buildStoreObject(d: any) { if (version.constructor !== Number || version < 0) version = -42; // invalid on purpose so it will reset else { - version = (version as number + 1).toString(); + version = ((version as number) + 1).toString(); } // check if version exists @@ -190,15 +189,19 @@ export function versionedStoreBuilder(): any { } // register helper - if (type === "instance") this._data.instanceHelpers[name as string] = helper; - else if (type === "static") this._data.staticHelpers[name as string] = helper; + if (type === "instance") + this._data.instanceHelpers[name as string] = helper; + else if (type === "static") + this._data.staticHelpers[name as string] = helper; return this; }, build() { // check if version list doesnt skip versions - const versionListSorted = this._data.versionList.sort((a: number, b: number) => a - b); + const versionListSorted = this._data.versionList.sort( + (a: number, b: number) => a - b + ); versionListSorted.forEach((v: any, i: number, arr: any[]) => { if (i === 0) return; if (v !== arr[i - 1] + 1) diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index 660e0ae4..5092ef4b 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -9,9 +9,57 @@ import { Loading } from "components/layout/Loading"; import { Tagline } from "components/Text/Tagline"; import { Title } from "components/Text/Title"; import { useDebounce } from "hooks/useDebounce"; +import { useLoading } from "hooks/useLoading"; + +function SearchLoading() { + return ; +} + +function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) { + const [results, setResults] = useState([]); + const [runSearchQuery, loading, error, success] = useLoading( + (query: MWQuery) => SearchProviders(query) + ); + + useEffect(() => { + if (searchQuery.searchQuery !== "") runSearch(searchQuery); + }, [searchQuery]); + + async function runSearch(query: MWQuery) { + const results = await runSearchQuery(query); + if (!results) return; + setResults(results); + } + + return ( +
+ {/* results */} + {success && results.length > 0 ? ( + + {results.map((v) => ( + + ))} + + ) : null} + + {/* no results */} + {success && results.length === 0 ?

No results found

: null} + + {/* error */} + {error ?

All scrapers failed

: null} + + {/* Loading icon */} + {loading ? : null} +
+ ); +} export function SearchView() { - const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [loading, setLoading] = useState(false); const [search, setSearch] = useState({ searchQuery: "", type: MWMediaType.MOVIE, @@ -19,19 +67,12 @@ export function SearchView() { const debouncedSearch = useDebounce(search, 2000); useEffect(() => { - if (debouncedSearch.searchQuery !== "") runSearch(debouncedSearch); - }, [debouncedSearch]); - useEffect(() => { - setResults([]); + setSearching(search.searchQuery !== ""); + setLoading(search.searchQuery !== ""); }, [search]); - - async function runSearch(query: MWQuery) { - const results = await SearchProviders(query); - setResults(results); - } - - const isLoading = search.searchQuery !== "" && results.length === 0; - const hasResult = results.length > 0; + useEffect(() => { + setLoading(false); + }, [debouncedSearch]); return ( @@ -48,18 +89,11 @@ export function SearchView() { /> - {/* results */} - {hasResult ? ( - - {results.map((v) => ( - - ))} - - ) : null} - - {/* Loading icon */} - {isLoading ? ( - + {/* results view */} + {loading ? ( + + ) : searching ? ( + ) : null} );