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 {
convertMediaToPortable,
getProviderFromId,
MWMediaMeta,
MWMediaType,
} from "@/providers";
import { serializePortableMedia } from "@/hooks/usePortableMedia";
import { DotList } from "@/components/text/DotList";
import { MWSearchResult } from "@/backend/metadata/search";
import { MWMediaType } from "@/providers";
export interface MediaCardProps {
media: MWMediaMeta;
// eslint-disable-next-line react/no-unused-prop-types
watchedPercentage: number;
media: MWSearchResult;
linkable?: boolean;
series?: boolean;
}
// TODO add progress back
function MediaCardContent({ media, series, linkable }: MediaCardProps) {
const provider = getProviderFromId(media.providerId);
if (!provider) {
return null;
}
function MediaCardContent({ media, linkable }: MediaCardProps) {
return (
<div
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" : ""
}`}
>
<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">
<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>
<DotList
className="text-xs"
content={[provider.displayName, media.mediaType, media.year]}
/>
<DotList className="text-xs" content={[media.type, media.year]} />
</article>
</div>
);
@ -56,17 +39,13 @@ function MediaCardContent({ media, series, linkable }: MediaCardProps) {
export function MediaCard(props: MediaCardProps) {
let link = "movie";
if (props.media.mediaType === MWMediaType.SERIES) link = "series";
if (props.media.type === MWMediaType.SERIES) link = "series";
const content = <MediaCardContent {...props} />;
if (!props.linkable) return <span>{content}</span>;
return (
<Link
to={`/media/${link}/${serializePortableMedia(
convertMediaToPortable(props.media)
)}`}
>
<Link to={`/media/${link}/${encodeURIComponent(props.media.id)}`}>
{content}
</Link>
);

View File

@ -1,23 +1,10 @@
import { MWMediaMeta } from "@/providers";
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
import { MWSearchResult } from "@/backend/metadata/search";
import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps {
media: MWMediaMeta;
series?: boolean;
media: MWSearchResult;
}
export function WatchedMediaCard(props: WatchedMediaCardProps) {
const { watched } = useWatchedContext();
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
/>
);
return <MediaCard media={props.media} linkable />;
}

View File

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

View File

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

View File

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

View File

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

View File

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