mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-21 14:47:41 +01:00
add search backend
This commit is contained in:
parent
46e933dfb7
commit
8268abc45d
10 changed files with 138 additions and 87 deletions
68
src/backend/metadata/search.ts
Normal file
68
src/backend/metadata/search.ts
Normal 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,
|
||||
}));
|
||||
}
|
1
src/backend/providermeta/.gitkeep
Normal file
1
src/backend/providermeta/.gitkeep
Normal file
|
@ -0,0 +1 @@
|
|||
this folder will be used for provider helper methods and the like
|
1
src/backend/providers/.gitkeep
Normal file
1
src/backend/providers/.gitkeep
Normal file
|
@ -0,0 +1 @@
|
|||
the new list of all providers, the old ones will go and be rewritten
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -85,6 +85,15 @@ export const VideoProgressStore = versionedStoreBuilder()
|
|||
|
||||
return output;
|
||||
},
|
||||
})
|
||||
.addVersion({
|
||||
version: 2,
|
||||
migrate() {
|
||||
// TODO actually migrate
|
||||
return {
|
||||
items: [],
|
||||
};
|
||||
},
|
||||
create() {
|
||||
return {
|
||||
items: [],
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue