mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-27 20:07:50 -04:00
Add manual serie/movie add
This commit is contained in:
parent
b3a48b9206
commit
fd673d1f0f
@ -315,6 +315,15 @@
|
||||
"progress-running": "{{count}} scanning",
|
||||
"progress-pending": "{{count}} pending",
|
||||
"progress-failed": "{{count}} failed"
|
||||
},
|
||||
"add": {
|
||||
"title": "Add to library",
|
||||
"searchPlaceholder": "Search for a movie or series...",
|
||||
"year": "Year",
|
||||
"movies": "Movies",
|
||||
"series": "Series",
|
||||
"noResults": "No results found",
|
||||
"typeToSearch": "Type a name to search for movies or series"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
front/src/app/(app)/admin/add.tsx
Normal file
3
front/src/app/(app)/admin/add.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { AddPage } from "~/ui/admin/add";
|
||||
|
||||
export default AddPage;
|
||||
@ -3,6 +3,7 @@ export * from "./entry";
|
||||
export * from "./extra";
|
||||
export * from "./kyoo-error";
|
||||
export * from "./movie";
|
||||
export * from "./search";
|
||||
export * from "./season";
|
||||
export * from "./serie";
|
||||
export * from "./show";
|
||||
@ -10,6 +11,7 @@ export * from "./studio";
|
||||
export * from "./user";
|
||||
export * from "./utils/genre";
|
||||
export * from "./utils/images";
|
||||
export * from "./utils/metadata";
|
||||
export * from "./utils/page";
|
||||
export * from "./video";
|
||||
export * from "./video-info";
|
||||
|
||||
28
front/src/models/search.ts
Normal file
28
front/src/models/search.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { z } from "zod/v4";
|
||||
import { Metadata } from "./utils/metadata";
|
||||
import { zdate } from "./utils/utils";
|
||||
|
||||
export const SearchMovie = z.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
airDate: zdate().nullable(),
|
||||
poster: z.string().nullable(),
|
||||
originalLanguage: z.string().nullable(),
|
||||
externalId: Metadata,
|
||||
});
|
||||
export type SearchMovie = z.infer<typeof SearchMovie>;
|
||||
|
||||
export const SearchSerie = z.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
startAir: zdate().nullable(),
|
||||
endAir: zdate().nullable(),
|
||||
poster: z.string().nullable(),
|
||||
originalLanguage: z.string().nullable(),
|
||||
externalId: Metadata,
|
||||
});
|
||||
export type SearchSerie = z.infer<typeof SearchSerie>;
|
||||
@ -40,7 +40,8 @@ export const ImageBackground = ({
|
||||
);
|
||||
}
|
||||
|
||||
const uri = `${apiUrl}${src[quality ?? "high"]}`;
|
||||
const path = src[quality ?? "high"];
|
||||
const uri = path.startsWith("http") ? path : `${apiUrl}${path}`;
|
||||
return (
|
||||
<ImgBg
|
||||
recyclingKey={uri}
|
||||
|
||||
@ -33,7 +33,8 @@ export const Image = ({
|
||||
);
|
||||
}
|
||||
|
||||
const uri = `${apiUrl}${src[quality ?? "high"]}`;
|
||||
const path = src[quality ?? "high"];
|
||||
const uri = path.startsWith("http") ? path : `${apiUrl}${path}`;
|
||||
return (
|
||||
<Img
|
||||
recyclingKey={uri}
|
||||
|
||||
@ -19,6 +19,7 @@ export * from "./progress";
|
||||
export * from "./select";
|
||||
export * from "./skeleton";
|
||||
export * from "./slider";
|
||||
export * from "./tabs";
|
||||
export * from "./text";
|
||||
export * from "./tooltip";
|
||||
|
||||
|
||||
@ -3,12 +3,14 @@ import { TextInput, type TextInputProps, View } from "react-native";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
export const Input = ({
|
||||
left,
|
||||
right,
|
||||
containerClassName,
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
containerClassName?: string;
|
||||
ref?: Ref<TextInput>;
|
||||
@ -21,6 +23,7 @@ export const Input = ({
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{left}
|
||||
<TextInput
|
||||
ref={ref}
|
||||
textAlignVertical="center"
|
||||
|
||||
72
front/src/primitives/tabs.tsx
Normal file
72
front/src/primitives/tabs.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { type Falsy, Pressable, View } from "react-native";
|
||||
import { cn } from "~/utils";
|
||||
import { Icon, type Icon as IconType } from "./icons";
|
||||
import { P } from "./text";
|
||||
|
||||
export const Tabs = <T,>({
|
||||
tabs: _tabs,
|
||||
value,
|
||||
setValue,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
tabs: {
|
||||
label: string;
|
||||
value: T;
|
||||
icon: IconType;
|
||||
}[];
|
||||
value: string;
|
||||
setValue: (value: T) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const tabs = _tabs.filter((x) => x) as {
|
||||
label: string;
|
||||
value: T;
|
||||
icon: IconType;
|
||||
}[];
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"flex-row items-center overflow-hidden rounded-4xl border-3 border-accent p-1",
|
||||
disabled && "border-slate-600",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{tabs.map((x) => (
|
||||
<Pressable
|
||||
key={`${x.value}`}
|
||||
disabled={disabled}
|
||||
onPress={() => setValue(x.value)}
|
||||
className={cn(
|
||||
"group flex-row items-center justify-center rounded-3xl px-4 py-2 outline-0",
|
||||
!(x.value === value) && "hover:bg-accent focus:bg-accent",
|
||||
x.value === value && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={x.icon}
|
||||
className={cn(
|
||||
"mx-1",
|
||||
x.value === value
|
||||
? "fill-slate-200"
|
||||
: "group-hover:fill-slate-200 group-focus:fill-slate-200",
|
||||
)}
|
||||
/>
|
||||
<P
|
||||
className={cn(
|
||||
"ml-1",
|
||||
x.value === value
|
||||
? "text-slate-200"
|
||||
: "group-hover:text-slate-200 group-focus:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{x.label}
|
||||
</P>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
263
front/src/ui/admin/add.tsx
Normal file
263
front/src/ui/admin/add.tsx
Normal file
@ -0,0 +1,263 @@
|
||||
import Add from "@material-symbols/svg-400/rounded/add.svg";
|
||||
import MovieIcon from "@material-symbols/svg-400/rounded/movie.svg";
|
||||
import OpenInNew from "@material-symbols/svg-400/rounded/open_in_new.svg";
|
||||
import SearchIcon from "@material-symbols/svg-400/rounded/search-fill.svg";
|
||||
import TVIcon from "@material-symbols/svg-400/rounded/tv.svg";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import type { Metadata } from "~/models";
|
||||
import { SearchMovie, SearchSerie } from "~/models";
|
||||
import {
|
||||
HR,
|
||||
IconButton,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
P,
|
||||
PosterBackground,
|
||||
Skeleton,
|
||||
SubP,
|
||||
Tabs,
|
||||
tooltip,
|
||||
} from "~/primitives";
|
||||
import { InfiniteFetch, type QueryIdentifier, useMutation } from "~/query";
|
||||
import { cn, getDisplayDate, useQueryState } from "~/utils";
|
||||
import { EmptyView } from "../empty-view";
|
||||
|
||||
const ExternalIdLinks = ({ externalId }: { externalId: Metadata }) => {
|
||||
const links = Object.entries(externalId).flatMap(([provider, ids]) =>
|
||||
ids
|
||||
.filter((x) => x.link)
|
||||
.map((x) => ({ provider, link: x.link!, label: x.label })),
|
||||
);
|
||||
|
||||
if (links.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="absolute top-1 right-1 flex-row gap-1">
|
||||
{links.map(({ provider, link, label }) => (
|
||||
<IconButton
|
||||
key={`${provider}-${link}`}
|
||||
icon={OpenInNew}
|
||||
as={Link}
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="bg-gray-800/70 hover:bg-gray-800 focus:bg-gray-800"
|
||||
iconClassName="h-5 w-5 fill-slate-200 dark:fill-slate-200"
|
||||
{...tooltip(label ?? provider)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchResultItem = ({
|
||||
name,
|
||||
subtitle,
|
||||
poster,
|
||||
externalId,
|
||||
onSelect,
|
||||
isPending,
|
||||
}: {
|
||||
name: string;
|
||||
subtitle: string | null;
|
||||
poster: string | null;
|
||||
externalId: Metadata;
|
||||
onSelect: () => void;
|
||||
isPending: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onSelect}
|
||||
disabled={isPending}
|
||||
className="group items-center p-1 outline-0"
|
||||
>
|
||||
<PosterBackground
|
||||
src={
|
||||
poster
|
||||
? {
|
||||
id: poster,
|
||||
source: poster,
|
||||
blurhash: "",
|
||||
low: poster,
|
||||
medium: poster,
|
||||
high: poster,
|
||||
}
|
||||
: null
|
||||
}
|
||||
quality="medium"
|
||||
className={cn(
|
||||
"w-full",
|
||||
"ring-accent group-hover:ring-3 group-focus-visible:ring-3",
|
||||
)}
|
||||
>
|
||||
{isPending && (
|
||||
<View className="absolute inset-0 items-center justify-center bg-black/50">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
)}
|
||||
<ExternalIdLinks externalId={externalId} />
|
||||
</PosterBackground>
|
||||
<P
|
||||
numberOfLines={subtitle ? 1 : 2}
|
||||
className="text-center group-focus-within:underline group-hover:underline"
|
||||
>
|
||||
{name}
|
||||
</P>
|
||||
{subtitle && <SubP className="text-center">{subtitle}</SubP>}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
SearchResultItem.Loader = () => {
|
||||
return (
|
||||
<View className="w-full items-center p-1">
|
||||
<View className="aspect-2/3 w-full overflow-hidden rounded">
|
||||
<Skeleton variant="custom" className="h-full w-full" />
|
||||
</View>
|
||||
<Skeleton className="mt-1" />
|
||||
<Skeleton className="w-1/2" />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const AddHeader = ({
|
||||
query,
|
||||
setQuery,
|
||||
kind,
|
||||
setKind,
|
||||
}: {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
kind: "movie" | "serie";
|
||||
setKind: (k: "movie" | "serie") => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className="gap-3 p-4">
|
||||
<View className="flex-1 flex-wrap content-center items-center gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("admin.add.searchPlaceholder")}
|
||||
left={
|
||||
<IconButton icon={SearchIcon} {...tooltip(t("navbar.search"))} />
|
||||
}
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
<Tabs
|
||||
value={kind}
|
||||
setValue={setKind}
|
||||
tabs={[
|
||||
{
|
||||
icon: MovieIcon,
|
||||
label: t("admin.add.movies"),
|
||||
value: "movie",
|
||||
},
|
||||
{
|
||||
icon: TVIcon,
|
||||
label: t("admin.add.series"),
|
||||
value: "serie",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<HR />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useQueryState("q", "");
|
||||
const [kind, setKind] = useQueryState<"movie" | "serie">("kind", "movie");
|
||||
|
||||
const { mutateAsync, isPending } = useMutation<SearchMovie | SearchSerie>({
|
||||
method: "POST",
|
||||
path: ["scanner", kind === "movie" ? "movies" : "series"],
|
||||
compute: (item) => ({
|
||||
body: {
|
||||
title: item.name,
|
||||
year:
|
||||
"airDate" in item
|
||||
? item.airDate?.getFullYear()
|
||||
: item.startAir?.getFullYear(),
|
||||
externalId: Object.fromEntries(
|
||||
Object.entries(item.externalId).map(([k, v]) => [k, v[0].dataId]),
|
||||
),
|
||||
videos: [],
|
||||
},
|
||||
}),
|
||||
invalidate: null,
|
||||
});
|
||||
|
||||
if (query.length === 0) {
|
||||
return (
|
||||
<Modal icon={Add} title={t("admin.add.title")}>
|
||||
<AddHeader
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
kind={kind}
|
||||
setKind={setKind}
|
||||
/>
|
||||
<P className="self-center py-8 text-center">
|
||||
{t("admin.add.typeToSearch")}
|
||||
</P>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal icon={Add} title={t("admin.add.title")} scroll={false}>
|
||||
<InfiniteFetch
|
||||
layout={{
|
||||
layout: "grid",
|
||||
gap: 8,
|
||||
numColumns: { xs: 2, sm: 3, md: 4 },
|
||||
size: 200,
|
||||
}}
|
||||
query={AddPage.query(kind, query)}
|
||||
Header={
|
||||
<AddHeader
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
kind={kind}
|
||||
setKind={setKind}
|
||||
/>
|
||||
}
|
||||
Empty={<EmptyView message={t("admin.add.noResults")} />}
|
||||
Render={({ item }) => (
|
||||
<SearchResultItem
|
||||
name={item.name}
|
||||
subtitle={getDisplayDate(item)}
|
||||
poster={item.poster}
|
||||
externalId={item.externalId}
|
||||
onSelect={async () => {
|
||||
await mutateAsync(item);
|
||||
if (router.canGoBack()) router.back();
|
||||
}}
|
||||
isPending={isPending}
|
||||
/>
|
||||
)}
|
||||
Loader={SearchResultItem.Loader}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
AddPage.query = (
|
||||
kind: "movie" | "serie",
|
||||
query: string,
|
||||
): QueryIdentifier<SearchMovie | SearchSerie> => ({
|
||||
parser: kind === "movie" ? SearchMovie : SearchSerie,
|
||||
path: ["scanner", kind === "movie" ? "movies" : "series"],
|
||||
params: {
|
||||
query: query,
|
||||
},
|
||||
infinite: true,
|
||||
enabled: query.length > 0,
|
||||
});
|
||||
@ -13,6 +13,7 @@ import { useQueryState } from "~/utils";
|
||||
import { Header } from "../../details/header";
|
||||
import { AddVideoFooter, VideoListHeader } from "./headers";
|
||||
import { PathItem } from "./path-item";
|
||||
import { EmptyView } from "~/ui/empty-view";
|
||||
|
||||
export const useEditLinks = (
|
||||
slug: string,
|
||||
@ -102,11 +103,7 @@ export const VideosModal = () => {
|
||||
/>
|
||||
)}
|
||||
Loader={PathItem.Loader}
|
||||
Empty={
|
||||
<View className="flex-1">
|
||||
<P className="flex-1 self-center">{t("videos-map.no-video")}</P>
|
||||
</View>
|
||||
}
|
||||
Empty={<EmptyView message={t("videos-map.no-video")} />}
|
||||
Footer={<AddVideoFooter addTitle={addTitle} />}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@ -6,7 +6,7 @@ import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { z } from "zod/v4";
|
||||
import type { z } from "zod/v4";
|
||||
import { ScanRequest, Video } from "~/models";
|
||||
import {
|
||||
Button,
|
||||
@ -25,10 +25,11 @@ import {
|
||||
import {
|
||||
InfiniteFetch,
|
||||
type QueryIdentifier,
|
||||
useFetch,
|
||||
useInfiniteFetch,
|
||||
useMutation,
|
||||
} from "~/query";
|
||||
import { cn, useQueryState } from "~/utils";
|
||||
import { EmptyView } from "../empty-view";
|
||||
|
||||
type VideoT = z.infer<typeof Video>;
|
||||
|
||||
@ -224,7 +225,7 @@ export const UnmatchedPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useQueryState("q", "");
|
||||
|
||||
const { data: scanData } = useFetch(UnmatchedPage.scanQuery());
|
||||
const { items: scanData } = useInfiniteFetch(UnmatchedPage.scanQuery());
|
||||
const scanMap = useMemo(() => {
|
||||
if (!scanData) return new Map<string, ScanRequest>();
|
||||
const map = new Map<string, ScanRequest>();
|
||||
@ -258,7 +259,7 @@ export const UnmatchedPage = () => {
|
||||
)}
|
||||
Loader={() => <VideoItem.Loader />}
|
||||
Divider
|
||||
Empty={<P className="self-center py-8">{t("admin.unmatched.empty")}</P>}
|
||||
Empty={<EmptyView message={t("admin.unmatched.empty")} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -273,10 +274,10 @@ UnmatchedPage.query = (search?: string): QueryIdentifier<VideoT> => ({
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
UnmatchedPage.scanQuery = (): QueryIdentifier<ScanRequest[]> => ({
|
||||
parser: z.array(ScanRequest),
|
||||
UnmatchedPage.scanQuery = (): QueryIdentifier<ScanRequest> => ({
|
||||
parser: ScanRequest,
|
||||
path: ["scanner", "scan"],
|
||||
infinite: false,
|
||||
infinite: true,
|
||||
refetchInterval: 5000,
|
||||
options: {
|
||||
returnError: true,
|
||||
|
||||
@ -193,7 +193,10 @@ class TVDB(Provider):
|
||||
if x.get("first_air_time")
|
||||
else None,
|
||||
end_air=None,
|
||||
poster=x["image_url"],
|
||||
poster=x["image_url"]
|
||||
if x["image_url"]
|
||||
!= "https://artworks.thetvdb.com/banners/images/missing/series.jpg"
|
||||
else None,
|
||||
original_language=Language.get(x["primary_language"]),
|
||||
external_id={
|
||||
self.name: [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user