Add manual serie/movie add

This commit is contained in:
Zoe Roux 2026-03-22 13:07:18 +01:00
parent b3a48b9206
commit fd673d1f0f
No known key found for this signature in database
13 changed files with 399 additions and 15 deletions

View File

@ -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"
}
}
}

View File

@ -0,0 +1,3 @@
import { AddPage } from "~/ui/admin/add";
export default AddPage;

View File

@ -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";

View 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>;

View File

@ -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}

View File

@ -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}

View File

@ -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";

View File

@ -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"

View 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
View 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,
});

View File

@ -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>

View File

@ -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,

View File

@ -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: [