mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-06-09 06:34:56 +00:00
Add search & add for unmatched videos (#1388)
This commit is contained in:
+1
-1
@@ -38,7 +38,7 @@ PUBLIC_URL=http://localhost:8901
|
||||
# Set `verified` to true if you don't wanna manually verify users.
|
||||
EXTRA_CLAIMS='{"permissions": ["core.read", "core.play"], "verified": false}'
|
||||
# This is the permissions of the first user (aka the first user is admin)
|
||||
FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", "apikeys.read", "apikeys.write", "core.read", "core.write", "core.play", "scanner.trigger", "scanner.guess"], "verified": true}'
|
||||
FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", "apikeys.read", "apikeys.write", "core.read", "core.write", "core.play", "scanner.trigger", "scanner.guess", "scanner.search", "scanner.add"], "verified": true}'
|
||||
|
||||
# Guest (meaning unlogged in users) can be:
|
||||
# unauthorized (they need to connect before doing anything)
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ kyoo:
|
||||
|
||||
# auth settings
|
||||
auth:
|
||||
firstUserClaims: '{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "core.play", "scanner.trigger", "scanner.guess"], "verified": true}'
|
||||
firstUserClaims: '{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "core.play", "scanner.trigger", "scanner.guess", "scanner.search", "scanner.add"], "verified": true}'
|
||||
guestClaims: '{"permissions": ["core.read"], "verified": true}'
|
||||
extraClaims: '{"permissions": ["core.read", "core.play"], "verified": false}'
|
||||
protectedClaims: "permissions,verified"
|
||||
|
||||
@@ -309,12 +309,24 @@
|
||||
"rescan": "Rescan Library",
|
||||
"empty": "All videos are matched!",
|
||||
"search": "Search videos...",
|
||||
"match": "Match",
|
||||
"match-file": "Match {{file}}",
|
||||
"status-pending": "Pending",
|
||||
"status-running": "Scanning",
|
||||
"status-failed": "Failed",
|
||||
"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",
|
||||
"library": "Library",
|
||||
"movies": "Movies",
|
||||
"series": "Series",
|
||||
"noResults": "No results found",
|
||||
"typeToSearch": "Type a name to search for movies or series"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { AddPage } from "~/ui/admin/add";
|
||||
|
||||
export default AddPage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { MatchPage } from "~/ui/admin/match";
|
||||
|
||||
export default MatchPage;
|
||||
@@ -6,6 +6,7 @@ export * from "./entry-line";
|
||||
export const entryDisplayNumber = (entry: Partial<Entry>) => {
|
||||
switch (entry.kind) {
|
||||
case "episode":
|
||||
if (!entry.seasonNumber) return `SP${entry.episodeNumber}`;
|
||||
return `S${entry.seasonNumber}:E${entry.episodeNumber}`;
|
||||
case "special":
|
||||
return `SP${entry.number}`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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,
|
||||
})
|
||||
.transform((x) => ({ kind: "search-movie", ...x }));
|
||||
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,
|
||||
})
|
||||
.transform((x) => ({ kind: "search-serie", ...x }));
|
||||
export type SearchSerie = z.infer<typeof SearchSerie>;
|
||||
@@ -33,6 +33,7 @@ export const Video = z.object({
|
||||
createdAt: zdate(),
|
||||
updatedAt: zdate(),
|
||||
});
|
||||
export type Video = z.infer<typeof Video>;
|
||||
|
||||
export const FullVideo = Video.extend({
|
||||
entries: z.array(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
}
|
||||
| Falsy
|
||||
)[];
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
import Add from "@material-symbols/svg-400/rounded/add-fill.svg";
|
||||
import MovieIcon from "@material-symbols/svg-400/rounded/movie-fill.svg";
|
||||
import OpenInNew from "@material-symbols/svg-400/rounded/open_in_new-fill.svg";
|
||||
import SearchIcon from "@material-symbols/svg-400/rounded/search-fill.svg";
|
||||
import TVIcon from "@material-symbols/svg-400/rounded/tv-fill.svg";
|
||||
import Library from "@material-symbols/svg-400/rounded/video_library-fill.svg";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { type KImage, SearchMovie, SearchSerie, Show } from "~/models";
|
||||
import {
|
||||
CircularProgress,
|
||||
HR,
|
||||
type Icon,
|
||||
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 SearchResultItem = ({
|
||||
name,
|
||||
subtitle,
|
||||
poster,
|
||||
externalHref,
|
||||
onSelect,
|
||||
isPending,
|
||||
}: {
|
||||
name: string;
|
||||
subtitle: string | null;
|
||||
poster: KImage | null;
|
||||
externalHref: string | null;
|
||||
onSelect: () => void;
|
||||
isPending: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onSelect}
|
||||
disabled={isPending}
|
||||
className="group items-center p-1 outline-0"
|
||||
>
|
||||
<PosterBackground
|
||||
src={poster}
|
||||
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">
|
||||
<CircularProgress />
|
||||
</View>
|
||||
)}
|
||||
{externalHref && (
|
||||
<IconButton
|
||||
icon={OpenInNew}
|
||||
as={Link}
|
||||
href={externalHref}
|
||||
target="_blank"
|
||||
className="absolute top-1 right-1 bg-gray-800/70 hover:bg-gray-800 focus:bg-gray-800"
|
||||
iconClassName="h-5 w-5 fill-slate-200 dark:fill-slate-200"
|
||||
/>
|
||||
)}
|
||||
</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,
|
||||
allowLibrary,
|
||||
}: {
|
||||
query: string;
|
||||
setQuery: (q: string) => void;
|
||||
kind: "library" | "movie" | "serie";
|
||||
setKind: (k: "library" | "movie" | "serie") => void;
|
||||
allowLibrary: boolean;
|
||||
}) => {
|
||||
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={[
|
||||
allowLibrary && {
|
||||
icon: Library,
|
||||
label: t("admin.add.library"),
|
||||
value: "library",
|
||||
},
|
||||
{
|
||||
icon: MovieIcon,
|
||||
label: t("admin.add.movies"),
|
||||
value: "movie",
|
||||
},
|
||||
{
|
||||
icon: TVIcon,
|
||||
label: t("admin.add.series"),
|
||||
value: "serie",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<HR />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddPage = ({
|
||||
title,
|
||||
icon,
|
||||
allowLibrary,
|
||||
videos = [],
|
||||
}: {
|
||||
title?: string;
|
||||
icon?: Icon;
|
||||
allowLibrary: boolean;
|
||||
videos: {
|
||||
id: string;
|
||||
episodes: { season: number | null; episode: number }[];
|
||||
}[];
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useQueryState("q", "");
|
||||
const [kind, setKind] = useQueryState<"movie" | "serie" | "library">(
|
||||
"kind",
|
||||
allowLibrary ? "library" : "movie",
|
||||
);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const addShow = useMutation({
|
||||
method: "POST",
|
||||
path: ["scanner", kind === "movie" ? "movies" : "series"],
|
||||
compute: (item: SearchMovie | SearchSerie) => ({
|
||||
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: videos,
|
||||
},
|
||||
}),
|
||||
invalidate: null,
|
||||
});
|
||||
const matchExisting = useMutation({
|
||||
method: "PUT",
|
||||
path: ["api", "videos", "link"],
|
||||
compute: (item: Show) => ({
|
||||
body: videos.map((x) => ({
|
||||
id: x.id,
|
||||
for:
|
||||
item.kind === "serie"
|
||||
? x.episodes.map((ep) => {
|
||||
if (!ep.season)
|
||||
return { serie: item.slug, special: ep.episode };
|
||||
return {
|
||||
serie: item.slug,
|
||||
season: ep.season,
|
||||
episode: ep.episode,
|
||||
};
|
||||
})
|
||||
: [{ movie: item.slug }],
|
||||
})),
|
||||
}),
|
||||
invalidate: ["api", "videos", "unmatched"],
|
||||
});
|
||||
|
||||
if (kind !== "library" && query.length === 0) {
|
||||
return (
|
||||
<Modal icon={icon ?? Add} title={title ?? t("admin.add.title")}>
|
||||
<AddHeader
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
kind={kind}
|
||||
setKind={setKind}
|
||||
allowLibrary={allowLibrary}
|
||||
/>
|
||||
<P className="self-center py-8 text-center">
|
||||
{t("admin.add.typeToSearch")}
|
||||
</P>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
icon={icon ?? Add}
|
||||
title={title ?? t("admin.add.title")}
|
||||
scroll={false}
|
||||
>
|
||||
<InfiniteFetch
|
||||
layout={{
|
||||
layout: "grid",
|
||||
gap: 8,
|
||||
numColumns: { xs: 2, sm: 3, md: 4 },
|
||||
size: 200,
|
||||
}}
|
||||
query={
|
||||
(kind === "library"
|
||||
? AddPage.libraryQuery(query)
|
||||
: AddPage.query(kind, query)) as QueryIdentifier<
|
||||
SearchMovie | SearchSerie | Show
|
||||
>
|
||||
}
|
||||
Header={
|
||||
<AddHeader
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
kind={kind}
|
||||
setKind={setKind}
|
||||
allowLibrary={allowLibrary}
|
||||
/>
|
||||
}
|
||||
Empty={<EmptyView message={t("admin.add.noResults")} />}
|
||||
Render={({ item }) => (
|
||||
<SearchResultItem
|
||||
name={item.name}
|
||||
subtitle={getDisplayDate(item)}
|
||||
poster={
|
||||
typeof item.poster === "string"
|
||||
? {
|
||||
id: item.poster,
|
||||
source: item.poster,
|
||||
blurhash: "",
|
||||
low: item.poster,
|
||||
medium: item.poster,
|
||||
high: item.poster,
|
||||
}
|
||||
: item.poster
|
||||
}
|
||||
externalHref={
|
||||
item.kind.startsWith("search")
|
||||
? Object.values(item.externalId)
|
||||
.flatMap((ids) => ids.map((x) => x.link))
|
||||
.filter((x) => x)[0]
|
||||
: null
|
||||
}
|
||||
onSelect={async () => {
|
||||
setSelected(item.id);
|
||||
if (item.kind.startsWith("search"))
|
||||
await addShow.mutateAsync(item as SearchMovie | SearchSerie);
|
||||
else await matchExisting.mutateAsync(item as Show);
|
||||
setSelected(null);
|
||||
if (router.canGoBack()) router.back();
|
||||
}}
|
||||
isPending={selected === item.id}
|
||||
/>
|
||||
)}
|
||||
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,
|
||||
});
|
||||
|
||||
AddPage.libraryQuery = (query: string): QueryIdentifier<Show> => ({
|
||||
parser: Show,
|
||||
path: ["api", "shows"],
|
||||
params: {
|
||||
query: query,
|
||||
},
|
||||
infinite: true,
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Video } from "~/models";
|
||||
import { type QueryIdentifier, useFetch } from "~/query";
|
||||
import { useQueryState } from "~/utils";
|
||||
import { AddPage } from "./add";
|
||||
|
||||
export const MatchPage = () => {
|
||||
const [id] = useQueryState("id", undefined!);
|
||||
const { data } = useFetch(MatchPage.query(id));
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AddPage
|
||||
icon={Search}
|
||||
title={t("admin.unmatched.match-file", { file: data?.path ?? "" })}
|
||||
videos={[{ id: id, episodes: data?.guess.episodes ?? [] }]}
|
||||
allowLibrary
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
MatchPage.query = (id: string): QueryIdentifier<Video> => ({
|
||||
parser: Video,
|
||||
path: ["api", "videos", id],
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { type Entry, type Episode, FullVideo, type Page } from "~/models";
|
||||
import { Modal, P } from "~/primitives";
|
||||
import { Modal } from "~/primitives";
|
||||
import {
|
||||
InfiniteFetch,
|
||||
type QueryIdentifier,
|
||||
useFetch,
|
||||
useMutation,
|
||||
} from "~/query";
|
||||
import { EmptyView } from "~/ui/empty-view";
|
||||
import { useQueryState } from "~/utils";
|
||||
import { Header } from "../../details/header";
|
||||
import { AddVideoFooter, VideoListHeader } from "./headers";
|
||||
@@ -35,12 +35,12 @@ export const useEditLinks = (
|
||||
for: entries.map((x) => {
|
||||
if (x.slug) return { slug: x.slug };
|
||||
const ep = x as Episode;
|
||||
if (ep.seasonNumber === 0)
|
||||
if (!ep.seasonNumber)
|
||||
return { serie: slug, special: ep.episodeNumber };
|
||||
return {
|
||||
serie: slug,
|
||||
season: ep.seasonNumber,
|
||||
episoed: ep.episodeNumber,
|
||||
episode: ep.episodeNumber,
|
||||
};
|
||||
}),
|
||||
},
|
||||
@@ -102,11 +102,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>;
|
||||
|
||||
@@ -70,43 +71,51 @@ const VideoItem = ({
|
||||
const episodes = item.guess.episodes;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={menuOpen ? undefined : `/watch/${item.id}`}
|
||||
onLongPress={() => setMenuOpen(true)}
|
||||
className="group flex-row"
|
||||
>
|
||||
<View className="w-20 items-center">
|
||||
<IconButton
|
||||
icon={Play}
|
||||
iconClassName="fill-accent"
|
||||
{...tooltip(t("show.play"), true)}
|
||||
/>
|
||||
<ScanStatusBadge status={scanStatus?.status ?? null} />
|
||||
</View>
|
||||
<View className="mr-4 flex-1">
|
||||
<P className="wrap-anywhere flex-1 text-sm">{item.path}</P>
|
||||
<View className="mt-1 flex-row flex-wrap items-center gap-2">
|
||||
<SubP className="font-semibold">{item.guess.title}</SubP>
|
||||
{item.guess.kind && (
|
||||
<View className="rounded bg-card px-1.5 py-0.5">
|
||||
<SubP className="text-xs">{item.guess.kind}</SubP>
|
||||
<View className="group flex-row">
|
||||
<Link
|
||||
href={menuOpen ? undefined : `/watch/${item.id}`}
|
||||
onLongPress={() => setMenuOpen(true)}
|
||||
className="flex-1 flex-row"
|
||||
>
|
||||
<View className="w-20 items-center">
|
||||
<IconButton
|
||||
icon={Play}
|
||||
iconClassName="fill-accent"
|
||||
{...tooltip(t("show.play"), true)}
|
||||
/>
|
||||
<ScanStatusBadge status={scanStatus?.status ?? null} />
|
||||
</View>
|
||||
<View className="mr-4 flex-1">
|
||||
<P className="wrap-anywhere flex-1 text-sm">{item.path}</P>
|
||||
<View className="mt-1 flex-row flex-wrap items-center gap-2">
|
||||
<SubP className="font-semibold">{item.guess.title}</SubP>
|
||||
{item.guess.kind && (
|
||||
<View className="rounded bg-card px-1.5 py-0.5">
|
||||
<SubP className="text-xs">{item.guess.kind}</SubP>
|
||||
</View>
|
||||
)}
|
||||
{episodes.length > 0 && (
|
||||
<SubP className="font-semibold">
|
||||
{episodes
|
||||
.map((x) => `S${x.season ?? "?"}E${x.episode}`)
|
||||
.join(", ")}
|
||||
</SubP>
|
||||
)}
|
||||
{item.version > 1 && <SubP>v{item.version}</SubP>}
|
||||
</View>
|
||||
{scanStatus?.status === "failed" && scanStatus.error && (
|
||||
<View className="mt-2 rounded bg-card p-2">
|
||||
<SubP className="text-xs">{scanStatus.error.message}</SubP>
|
||||
</View>
|
||||
)}
|
||||
{episodes.length > 0 && (
|
||||
<SubP className="font-semibold">
|
||||
{episodes
|
||||
.map((x) => `S${x.season ?? "?"}E${x.episode}`)
|
||||
.join(", ")}
|
||||
</SubP>
|
||||
)}
|
||||
{item.version > 1 && <SubP>v{item.version}</SubP>}
|
||||
</View>
|
||||
{scanStatus?.status === "failed" && scanStatus.error && (
|
||||
<View className="mt-2 rounded bg-card p-2">
|
||||
<SubP className="text-xs">{scanStatus.error.message}</SubP>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Link>
|
||||
<IconButton
|
||||
icon={Search}
|
||||
as={Link}
|
||||
href={`/admin/match/${item.id}?q=${item.guess.title}`}
|
||||
{...tooltip(t("admin.unmatched.match"))}
|
||||
/>
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={MoreVert}
|
||||
@@ -114,13 +123,19 @@ const VideoItem = ({
|
||||
setOpen={setMenuOpen}
|
||||
{...tooltip(t("misc.more"))}
|
||||
>
|
||||
<Menu.Item
|
||||
label={t("admin.unmatched.match")}
|
||||
icon={Search}
|
||||
href={`/admin/match/${item.id}?q=${item.guess.title}`}
|
||||
/>
|
||||
<HR />
|
||||
<Menu.Item
|
||||
label={t("home.episodeMore.mediainfo")}
|
||||
icon={MovieInfo}
|
||||
href={`/info/${item.id}`}
|
||||
/>
|
||||
</Menu>
|
||||
</Link>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -224,7 +239,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 +273,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 +288,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,
|
||||
|
||||
@@ -18,7 +18,7 @@ from .routers.routes import router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_):
|
||||
async def lifespan(app: FastAPI):
|
||||
async with (
|
||||
init_pool() as pool,
|
||||
get_db() as db,
|
||||
@@ -26,6 +26,7 @@ async def lifespan(_):
|
||||
TVDB() as tvdb,
|
||||
TheMovieDatabase() as tmdb,
|
||||
):
|
||||
app.state.provider = CompositeProvider(tvdb, tmdb)
|
||||
# there's no way someone else used the same id, right?
|
||||
is_master = await db.fetchval("select pg_try_advisory_lock(198347)")
|
||||
is_http = not is_master and await db.fetchval(
|
||||
@@ -39,7 +40,7 @@ async def lifespan(_):
|
||||
processor = RequestProcessor(
|
||||
pool,
|
||||
client,
|
||||
CompositeProvider(tvdb, tmdb),
|
||||
app.state.provider,
|
||||
)
|
||||
scanner = FsScanner(client, RequestCreator(db))
|
||||
tasks = create_task(
|
||||
|
||||
@@ -189,8 +189,6 @@ class FsScanner:
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: handle specials & movie as episodes (needs animelist or thexem)
|
||||
return video
|
||||
|
||||
def walk_fs(self, root_path: str) -> set[str]:
|
||||
|
||||
@@ -50,6 +50,7 @@ class MovieTranslation(Model):
|
||||
|
||||
|
||||
class SearchMovie(Model):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from ..utils import Model
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Page(Model, Generic[T]):
|
||||
items: list[T]
|
||||
this_: str
|
||||
next: str | None = None
|
||||
@@ -21,6 +21,13 @@ class Request(Model, extra="allow"):
|
||||
episodes: list[Guess.Episode]
|
||||
|
||||
|
||||
class CreateRequest(Model):
|
||||
title: str
|
||||
year: int | None
|
||||
external_id: dict[str, str]
|
||||
videos: list[Request.Video]
|
||||
|
||||
|
||||
class RequestRet(Model):
|
||||
id: str
|
||||
kind: Literal["episode", "movie"]
|
||||
|
||||
@@ -57,6 +57,7 @@ class SerieTranslation(Model):
|
||||
|
||||
|
||||
class SearchSerie(Model):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
@@ -111,13 +111,14 @@ class TheMovieDatabase(Provider):
|
||||
params={
|
||||
"query": title,
|
||||
"year": year,
|
||||
"languages": [str(x) for x in language],
|
||||
"language": next((str(x) for x in language), None),
|
||||
},
|
||||
)
|
||||
)["results"]
|
||||
search = self._sort_search(search, title, year)
|
||||
return [
|
||||
SearchMovie(
|
||||
id=x["id"],
|
||||
slug=to_slug(x["title"]),
|
||||
name=x["title"],
|
||||
description=x["overview"],
|
||||
@@ -245,13 +246,14 @@ class TheMovieDatabase(Provider):
|
||||
params={
|
||||
"query": title,
|
||||
"year": year,
|
||||
"languages": [str(x) for x in language],
|
||||
"language": next((str(x) for x in language), None),
|
||||
},
|
||||
)
|
||||
)["results"]
|
||||
search = self._sort_search(search, title, year)
|
||||
return [
|
||||
SearchSerie(
|
||||
id=x["id"],
|
||||
slug=to_slug(x["name"]),
|
||||
name=x["name"],
|
||||
description=x["overview"],
|
||||
|
||||
@@ -171,14 +171,32 @@ class TVDB(Provider):
|
||||
)
|
||||
return [
|
||||
SearchSerie(
|
||||
id=x["id"],
|
||||
slug=x["slug"],
|
||||
name=x["name"],
|
||||
description=x.get("overview"),
|
||||
name=next(
|
||||
(
|
||||
x["translations"][lang.to_alpha3()]
|
||||
for lang in language
|
||||
if "translations" in x and lang.to_alpha3() in x["translations"]
|
||||
),
|
||||
x["name"],
|
||||
),
|
||||
description=next(
|
||||
(
|
||||
x["overviews"][lang.to_alpha3()]
|
||||
for lang in language
|
||||
if "overviews" in x and lang.to_alpha3() in x["overviews"]
|
||||
),
|
||||
x.get("overview"),
|
||||
),
|
||||
start_air=datetime.strptime(x["first_air_time"], "%Y-%m-%d").date()
|
||||
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: [
|
||||
|
||||
@@ -8,7 +8,7 @@ from opentelemetry import trace
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from .client import KyooClient
|
||||
from .models.request import Request
|
||||
from .models.request import Request, RequestRet
|
||||
from .models.videos import Resource
|
||||
from .providers.provider import Provider, ProviderError
|
||||
|
||||
@@ -21,13 +21,15 @@ class RequestCreator:
|
||||
self._database = database
|
||||
|
||||
async def enqueue(self, requests: list[Request]):
|
||||
await self._database.executemany(
|
||||
ret = await self._database.fetchmany(
|
||||
"""
|
||||
insert into scanner.requests(kind, title, year, external_id, videos)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (kind, title, year)
|
||||
do update set
|
||||
videos = requests.videos || excluded.videos
|
||||
returning
|
||||
pk::text as id
|
||||
""",
|
||||
[
|
||||
[x["kind"], x["title"], x["year"], x["external_id"], x["videos"]]
|
||||
@@ -35,6 +37,19 @@ class RequestCreator:
|
||||
],
|
||||
)
|
||||
_ = await self._database.execute("notify scanner_requests")
|
||||
return [
|
||||
RequestRet(
|
||||
id=x["id"],
|
||||
kind=req.kind,
|
||||
title=req.title,
|
||||
year=req.year,
|
||||
status="pending",
|
||||
videos=[v.id for v in req.videos],
|
||||
error=None,
|
||||
started_at=None,
|
||||
)
|
||||
for req, x in zip(requests, ret)
|
||||
]
|
||||
|
||||
async def clear_failed(self):
|
||||
_ = await self._database.execute(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, Request
|
||||
from langcodes import Language
|
||||
|
||||
from ..database import get_db
|
||||
from ..providers.composite import CompositeProvider
|
||||
from ..requests import RequestCreator
|
||||
|
||||
|
||||
def get_provider(request: Request) -> CompositeProvider:
|
||||
return request.app.state.provider
|
||||
|
||||
|
||||
async def get_request_creator():
|
||||
async with get_db() as db:
|
||||
yield RequestCreator(db)
|
||||
|
||||
|
||||
def get_preferred_languages(
|
||||
accept_language: Annotated[str | None, Header()] = None,
|
||||
) -> list[Language]:
|
||||
if not accept_language:
|
||||
return []
|
||||
|
||||
ret: list[tuple[float, int, Language]] = []
|
||||
for index, item in enumerate(accept_language.split(",")):
|
||||
part = item.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
tag, *params = [x.strip() for x in part.split(";")]
|
||||
if tag == "*":
|
||||
continue
|
||||
|
||||
try:
|
||||
q = next((float(x[2:]) for x in params if x.startswith("q=")), 1)
|
||||
if q <= 0:
|
||||
continue
|
||||
|
||||
language = Language.get(tag)
|
||||
ret.append((q, index, language))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
ret.sort(key=lambda x: (-x[0], x[1]))
|
||||
return [x for _q, _i, x in ret] + [Language.get("en")]
|
||||
@@ -1,12 +1,26 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Security
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
Request as HttpRequest,
|
||||
Security,
|
||||
)
|
||||
|
||||
from ..fsscan import create_scanner
|
||||
from ..identifiers.identify import identify
|
||||
from ..jwt import validate_bearer
|
||||
from ..models.request import RequestRet
|
||||
from ..models.movie import SearchMovie
|
||||
from ..models.page import Page
|
||||
from ..models.request import CreateRequest, Request, RequestRet
|
||||
from ..models.serie import SearchSerie
|
||||
from ..models.videos import Video
|
||||
from ..providers.composite import CompositeProvider
|
||||
from ..requests import RequestCreator
|
||||
from ..status import StatusService
|
||||
from ..utils import Language
|
||||
from .dependencies import get_preferred_languages, get_provider, get_request_creator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -14,14 +28,16 @@ router = APIRouter()
|
||||
@router.get("/scan")
|
||||
async def get_scan_status(
|
||||
svc: Annotated[StatusService, Depends(StatusService.create)],
|
||||
request: HttpRequest,
|
||||
_: Annotated[None, Security(validate_bearer, scopes=["scanner.trigger"])],
|
||||
status: Literal["pending", "running", "failed"] | None = None,
|
||||
) -> list[RequestRet]:
|
||||
) -> Page[RequestRet]:
|
||||
"""
|
||||
Get scan status, know what tasks are running, pending or failed.
|
||||
"""
|
||||
|
||||
return await svc.list_requests(status=status)
|
||||
items = await svc.list_requests(status=status)
|
||||
return Page(items=items, this_=str(request.url), next=None)
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -52,9 +68,107 @@ async def trigger_scan(
|
||||
async def get_guess(
|
||||
path: str,
|
||||
_: Annotated[None, Security(validate_bearer, scopes=["scanner.guess"])],
|
||||
):
|
||||
) -> Video:
|
||||
"""
|
||||
Identify a video path and return a serie/movie guess.
|
||||
"""
|
||||
|
||||
return await identify(path)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/movies",
|
||||
status_code=200,
|
||||
response_description="Found movies",
|
||||
)
|
||||
async def get_movies(
|
||||
provider: Annotated[CompositeProvider, Depends(get_provider)],
|
||||
language: Annotated[list[Language], Depends(get_preferred_languages)],
|
||||
request: HttpRequest,
|
||||
_: Annotated[None, Security(validate_bearer, scopes=["scanner.search"])],
|
||||
query: str,
|
||||
year: int | None = None,
|
||||
) -> Page[SearchMovie]:
|
||||
"""
|
||||
Search for a movie
|
||||
"""
|
||||
|
||||
items = await provider.search_movies(query, year=year, language=language)
|
||||
return Page(items=items, this_=str(request.url), next=None)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/series",
|
||||
status_code=200,
|
||||
response_description="Found series",
|
||||
)
|
||||
async def get_series(
|
||||
provider: Annotated[CompositeProvider, Depends(get_provider)],
|
||||
language: Annotated[list[Language], Depends(get_preferred_languages)],
|
||||
request: HttpRequest,
|
||||
_: Annotated[None, Security(validate_bearer, scopes=["scanner.search"])],
|
||||
query: str,
|
||||
year: int | None = None,
|
||||
) -> Page[SearchSerie]:
|
||||
"""
|
||||
Search for a serie
|
||||
"""
|
||||
|
||||
items = await provider.search_series(query, year=year, language=language)
|
||||
return Page(items=items, this_=str(request.url), next=None)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/movies",
|
||||
status_code=201,
|
||||
response_description="Movie metadata request created.",
|
||||
)
|
||||
async def create_movie(
|
||||
body: CreateRequest,
|
||||
requests: Annotated[RequestCreator, Depends(get_request_creator)],
|
||||
_: Annotated[None, Security(validate_bearer, scopes=["scanner.add"])],
|
||||
) -> RequestRet:
|
||||
"""
|
||||
Create a movie metadata request.
|
||||
"""
|
||||
|
||||
[ret] = await requests.enqueue(
|
||||
[
|
||||
Request(
|
||||
kind="movie",
|
||||
title=body.title,
|
||||
year=body.year,
|
||||
external_id=body.external_id,
|
||||
videos=body.videos,
|
||||
)
|
||||
]
|
||||
)
|
||||
return ret
|
||||
|
||||
|
||||
@router.post(
|
||||
"/series",
|
||||
status_code=201,
|
||||
response_description="Series metadata request created.",
|
||||
)
|
||||
async def create_serie(
|
||||
body: CreateRequest,
|
||||
requests: Annotated[RequestCreator, Depends(get_request_creator)],
|
||||
_: Annotated[None, Security(validate_bearer, scopes=["scanner.add"])],
|
||||
) -> RequestRet:
|
||||
"""
|
||||
Create a series metadata request.
|
||||
"""
|
||||
|
||||
[ret] = await requests.enqueue(
|
||||
[
|
||||
Request(
|
||||
kind="episode",
|
||||
title=body.title,
|
||||
year=body.year,
|
||||
external_id=body.external_id,
|
||||
videos=body.videos,
|
||||
)
|
||||
]
|
||||
)
|
||||
return ret
|
||||
|
||||
Reference in New Issue
Block a user