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 { 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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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: [],
|
||||||
|
|
|
@ -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: [],
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue