Add search & add for unmatched videos (#1388)

This commit is contained in:
2026-03-22 20:10:12 +01:00
committed by GitHub
29 changed files with 782 additions and 71 deletions
+1 -1
View File
@@ -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
View File
@@ -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"
+12
View File
@@ -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"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import { AddPage } from "~/ui/admin/add";
export default AddPage;
+3
View File
@@ -0,0 +1,3 @@
import { MatchPage } from "~/ui/admin/match";
export default MatchPage;
+1
View File
@@ -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}`;
+2
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";
+32
View File
@@ -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>;
+1
View File
@@ -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}
+2 -1
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}
+1
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";
+3
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"
+75
View File
@@ -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>
);
};
+324
View File
@@ -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,
});
+26
View File
@@ -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],
});
+5 -9
View File
@@ -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>
+57 -42
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>;
@@ -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,
+3 -2
View File
@@ -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(
-2
View File
@@ -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]:
+1
View File
@@ -50,6 +50,7 @@ class MovieTranslation(Model):
class SearchMovie(Model):
id: str
slug: str
name: str
description: str | None
+11
View File
@@ -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
+7
View File
@@ -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"]
+1
View File
@@ -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"],
+21 -3
View File
@@ -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: [
+17 -2
View File
@@ -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(
+47
View File
@@ -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")]
+119 -5
View File
@@ -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