add search backend

This commit is contained in:
Jelle van Snik 2023-01-10 22:43:27 +01:00
parent 46e933dfb7
commit 8268abc45d
10 changed files with 138 additions and 87 deletions

View File

@ -0,0 +1,68 @@
import { MWMediaType, MWQuery } from "@/providers";
const JW_API_BASE = "https://apis.justwatch.com";
type JWContentTypes = "movie" | "show";
type JWSearchQuery = {
content_types: JWContentTypes[];
page: number;
page_size: number;
query: string;
};
type JWSearchResults = {
title: string;
poster?: string;
id: number;
original_release_year: number;
jw_entity_id: string;
};
type JWPage<T> = {
items: T[];
page: number;
page_size: number;
total_pages: number;
total_results: number;
};
export type MWSearchResult = {
title: string;
id: string;
year: string;
poster?: string;
type: MWMediaType;
};
export async function searchForMedia({
searchQuery,
type,
}: MWQuery): Promise<MWSearchResult[]> {
const body: JWSearchQuery = {
content_types: [],
page: 1,
query: searchQuery,
page_size: 40,
};
if (type === MWMediaType.MOVIE) body.content_types.push("movie");
else if (type === MWMediaType.SERIES) body.content_types.push("show");
else if (type === MWMediaType.ANIME)
throw new Error("Anime search type is not supported");
const data = await fetch(
`${JW_API_BASE}/content/titles/en_US/popular?body=${encodeURIComponent(
JSON.stringify(body)
)}`
).then((res) => res.json() as Promise<JWPage<JWSearchResults>>);
return data.items.map<MWSearchResult>((v) => ({
title: v.title,
id: v.id.toString(),
year: v.original_release_year.toString(),
poster: v.poster
? `https://images.justwatch.com${v.poster.replace("{profile}", "s166")}`
: undefined,
type,
}));
}

View File

@ -0,0 +1 @@
this folder will be used for provider helper methods and the like

View File

@ -0,0 +1 @@
the new list of all providers, the old ones will go and be rewritten

View File

@ -1,30 +1,16 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import {
convertMediaToPortable,
getProviderFromId,
MWMediaMeta,
MWMediaType,
} from "@/providers";
import { serializePortableMedia } from "@/hooks/usePortableMedia";
import { DotList } from "@/components/text/DotList"; import { DotList } from "@/components/text/DotList";
import { MWSearchResult } from "@/backend/metadata/search";
import { MWMediaType } from "@/providers";
export interface MediaCardProps { export interface MediaCardProps {
media: MWMediaMeta; media: MWSearchResult;
// eslint-disable-next-line react/no-unused-prop-types
watchedPercentage: number;
linkable?: boolean; linkable?: boolean;
series?: boolean;
} }
// TODO add progress back // TODO add progress back
function MediaCardContent({ media, series, linkable }: MediaCardProps) { function MediaCardContent({ media, linkable }: MediaCardProps) {
const provider = getProviderFromId(media.providerId);
if (!provider) {
return null;
}
return ( return (
<div <div
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${ className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
@ -36,19 +22,16 @@ function MediaCardContent({ media, series, linkable }: MediaCardProps) {
linkable ? "group-hover:scale-95" : "" linkable ? "group-hover:scale-95" : ""
}`} }`}
> >
<div className="mb-4 aspect-[2/3] w-full rounded-xl bg-denim-500" /> <div
className="mb-4 aspect-[2/3] w-full rounded-xl bg-denim-500 bg-cover"
style={{
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
}}
/>
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3"> <h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
<span>{media.title}</span> <span>{media.title}</span>
{series && media.seasonId && media.episodeId ? (
<span className="ml-2 text-xs text-denim-700">
S{media.seasonId} E{media.episodeId}
</span>
) : null}
</h1> </h1>
<DotList <DotList className="text-xs" content={[media.type, media.year]} />
className="text-xs"
content={[provider.displayName, media.mediaType, media.year]}
/>
</article> </article>
</div> </div>
); );
@ -56,17 +39,13 @@ function MediaCardContent({ media, series, linkable }: MediaCardProps) {
export function MediaCard(props: MediaCardProps) { export function MediaCard(props: MediaCardProps) {
let link = "movie"; let link = "movie";
if (props.media.mediaType === MWMediaType.SERIES) link = "series"; if (props.media.type === MWMediaType.SERIES) link = "series";
const content = <MediaCardContent {...props} />; const content = <MediaCardContent {...props} />;
if (!props.linkable) return <span>{content}</span>; if (!props.linkable) return <span>{content}</span>;
return ( return (
<Link <Link to={`/media/${link}/${encodeURIComponent(props.media.id)}`}>
to={`/media/${link}/${serializePortableMedia(
convertMediaToPortable(props.media)
)}`}
>
{content} {content}
</Link> </Link>
); );

View File

@ -1,23 +1,10 @@
import { MWMediaMeta } from "@/providers"; import { MWSearchResult } from "@/backend/metadata/search";
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
import { MediaCard } from "./MediaCard"; import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps { export interface WatchedMediaCardProps {
media: MWMediaMeta; media: MWSearchResult;
series?: boolean;
} }
export function WatchedMediaCard(props: WatchedMediaCardProps) { export function WatchedMediaCard(props: WatchedMediaCardProps) {
const { watched } = useWatchedContext(); return <MediaCard media={props.media} linkable />;
const foundWatched = getWatchedFromPortable(watched.items, props.media);
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
return (
<MediaCard
watchedPercentage={watchedPercentage}
media={props.media}
series={props.series && props.media.episodeId !== undefined}
linkable
/>
);
} }

View File

@ -2,7 +2,12 @@ import React, { useMemo, useRef, useState } from "react";
export function useLoading<T extends (...args: any) => Promise<any>>( export function useLoading<T extends (...args: any) => Promise<any>>(
action: T action: T
) { ): [
(...args: Parameters<T>) => ReturnType<T> | Promise<undefined>,
boolean,
Error | undefined,
boolean
] {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState<any | undefined>(undefined); const [error, setError] = useState<any | undefined>(undefined);
@ -20,11 +25,11 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
const doAction = useMemo( const doAction = useMemo(
() => () =>
async (...args: Parameters<T>) => { async (...args: any) => {
setLoading(true); setLoading(true);
setSuccess(false); setSuccess(false);
setError(undefined); setError(undefined);
return new Promise((resolve) => { return new Promise<any>((resolve) => {
actionMemo(...args) actionMemo(...args)
.then((v) => { .then((v) => {
if (!isMounted.current) return resolve(undefined); if (!isMounted.current) return resolve(undefined);

View File

@ -4,6 +4,15 @@ export const BookmarkStore = versionedStoreBuilder()
.setKey("mw-bookmarks") .setKey("mw-bookmarks")
.addVersion({ .addVersion({
version: 0, version: 0,
})
.addVersion({
version: 1,
migrate() {
return {
// TODO actually migrate
bookmarks: [],
};
},
create() { create() {
return { return {
bookmarks: [], bookmarks: [],

View File

@ -85,6 +85,15 @@ export const VideoProgressStore = versionedStoreBuilder()
return output; return output;
}, },
})
.addVersion({
version: 2,
migrate() {
// TODO actually migrate
return {
items: [],
};
},
create() { create() {
return { return {
items: [], items: [],

View File

@ -1,6 +1,8 @@
import { searchForMedia } from "@/backend/metadata/search";
import { ProgressListenerControl } from "@/components/video/controls/ProgressListenerControl"; import { ProgressListenerControl } from "@/components/video/controls/ProgressListenerControl";
import { SourceControl } from "@/components/video/controls/SourceControl"; import { SourceControl } from "@/components/video/controls/SourceControl";
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer"; import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
import { MWMediaType } from "@/providers";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
// test videos: https://gist.github.com/jsturgis/3b19447b304616f18657 // test videos: https://gist.github.com/jsturgis/3b19447b304616f18657
@ -32,6 +34,14 @@ export function TestView() {
return <p onClick={handleClick}>Click me to show</p>; return <p onClick={handleClick}>Click me to show</p>;
} }
async function search() {
const test = await searchForMedia({
searchQuery: "tron",
type: MWMediaType.MOVIE,
});
console.log(test);
}
return ( return (
<div className="w-[40rem] max-w-full"> <div className="w-[40rem] max-w-full">
<DecoratedVideoPlayer> <DecoratedVideoPlayer>
@ -44,6 +54,7 @@ export function TestView() {
onProgress={(a, b) => console.log(a, b)} onProgress={(a, b) => console.log(a, b)}
/> />
</DecoratedVideoPlayer> </DecoratedVideoPlayer>
<p onClick={() => search()}>click me to search</p>
</div> </div>
); );
} }

View File

@ -6,38 +6,26 @@ import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid"; import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { useLoading } from "@/hooks/useLoading"; import { useLoading } from "@/hooks/useLoading";
import { MWMassProviderOutput, MWQuery, SearchProviders } from "@/providers"; import { MWQuery } from "@/providers";
import { MWSearchResult, searchForMedia } from "@/backend/metadata/search";
import { SearchLoadingView } from "./SearchLoadingView"; import { SearchLoadingView } from "./SearchLoadingView";
function SearchSuffix(props: { function SearchSuffix(props: { failed?: boolean; results?: number }) {
fails: number;
total: number;
resultsSize: number;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const allFailed: boolean = props.fails === props.total; const icon: Icons = props.failed ? Icons.WARNING : Icons.EYE_SLASH;
const icon: Icons = allFailed ? Icons.WARNING : Icons.EYE_SLASH;
return ( return (
<div className="mt-40 mb-24 flex flex-col items-center justify-center space-y-3 text-center"> <div className="mt-40 mb-24 flex flex-col items-center justify-center space-y-3 text-center">
<IconPatch <IconPatch
icon={icon} icon={icon}
className={`text-xl ${allFailed ? "text-red-400" : "text-bink-600"}`} className={`text-xl ${props.failed ? "text-red-400" : "text-bink-600"}`}
/> />
{/* standard suffix */} {/* standard suffix */}
{!allFailed ? ( {!props.failed ? (
<div> <div>
{props.fails > 0 ? ( {(props.results ?? 0) > 0 ? (
<p className="text-red-400">
{t("search.providersFailed", {
fails: props.fails,
total: props.total,
})}
</p>
) : null}
{props.resultsSize > 0 ? (
<p>{t("search.allResults")}</p> <p>{t("search.allResults")}</p>
) : ( ) : (
<p>{t("search.noResults")}</p> <p>{t("search.noResults")}</p>
@ -46,7 +34,7 @@ function SearchSuffix(props: {
) : null} ) : null}
{/* Error result */} {/* Error result */}
{allFailed ? ( {props.failed ? (
<div> <div>
<p>{t("search.allFailed")}</p> <p>{t("search.allFailed")}</p>
</div> </div>
@ -58,9 +46,9 @@ function SearchSuffix(props: {
export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) { export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [results, setResults] = useState<MWMassProviderOutput | undefined>(); const [results, setResults] = useState<MWSearchResult[]>([]);
const [runSearchQuery, loading, error] = useLoading((query: MWQuery) => const [runSearchQuery, loading, error] = useLoading((query: MWQuery) =>
SearchProviders(query) searchForMedia(query)
); );
useEffect(() => { useEffect(() => {
@ -74,32 +62,25 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
}, [searchQuery, runSearchQuery]); }, [searchQuery, runSearchQuery]);
if (loading) return <SearchLoadingView />; if (loading) return <SearchLoadingView />;
if (error) return <SearchSuffix resultsSize={0} fails={1} total={1} />; if (error) return <SearchSuffix failed />;
if (!results) return null; if (!results) return null;
return ( return (
<div> <div>
{results?.results.length > 0 ? ( {results.length > 0 ? (
<SectionHeading <SectionHeading
title={t("search.headingTitle") || "Search results"} title={t("search.headingTitle") || "Search results"}
icon={Icons.SEARCH} icon={Icons.SEARCH}
> >
<MediaGrid> <MediaGrid>
{results.results.map((v) => ( {results.map((v) => (
<WatchedMediaCard <WatchedMediaCard key={v.id.toString()} media={v} />
key={[v.mediaId, v.providerId].join("|")}
media={v}
/>
))} ))}
</MediaGrid> </MediaGrid>
</SectionHeading> </SectionHeading>
) : null} ) : null}
<SearchSuffix <SearchSuffix results={results.length} />
resultsSize={results.results.length}
fails={results.stats.failed}
total={results.stats.total}
/>
</div> </div>
); );
} }