diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 608a6f1b..3ba6dfb8 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -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" } } } diff --git a/front/src/app/(app)/admin/add.tsx b/front/src/app/(app)/admin/add.tsx new file mode 100644 index 00000000..5cec8acd --- /dev/null +++ b/front/src/app/(app)/admin/add.tsx @@ -0,0 +1,3 @@ +import { AddPage } from "~/ui/admin/add"; + +export default AddPage; diff --git a/front/src/models/index.ts b/front/src/models/index.ts index 08400360..8b3ec060 100644 --- a/front/src/models/index.ts +++ b/front/src/models/index.ts @@ -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"; diff --git a/front/src/models/search.ts b/front/src/models/search.ts new file mode 100644 index 00000000..adceb39e --- /dev/null +++ b/front/src/models/search.ts @@ -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; + +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; diff --git a/front/src/primitives/image/image-background.tsx b/front/src/primitives/image/image-background.tsx index 7f4ad4aa..92c0fa81 100644 --- a/front/src/primitives/image/image-background.tsx +++ b/front/src/primitives/image/image-background.tsx @@ -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 ( ; @@ -21,6 +23,7 @@ export const Input = ({ containerClassName, )} > + {left} ({ + 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 ( + + {tabs.map((x) => ( + 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", + )} + > + +

+ {x.label} +

+
+ ))} +
+ ); +}; diff --git a/front/src/ui/admin/add.tsx b/front/src/ui/admin/add.tsx new file mode 100644 index 00000000..1e9ee7f9 --- /dev/null +++ b/front/src/ui/admin/add.tsx @@ -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 ( + + {links.map(({ provider, link, label }) => ( + + ))} + + ); +}; + +const SearchResultItem = ({ + name, + subtitle, + poster, + externalId, + onSelect, + isPending, +}: { + name: string; + subtitle: string | null; + poster: string | null; + externalId: Metadata; + onSelect: () => void; + isPending: boolean; +}) => { + return ( + + + {isPending && ( + + + + )} + + +

+ {name} +

+ {subtitle && {subtitle}} +
+ ); +}; + +SearchResultItem.Loader = () => { + return ( + + + + + + + + ); +}; + +const AddHeader = ({ + query, + setQuery, + kind, + setKind, +}: { + query: string; + setQuery: (q: string) => void; + kind: "movie" | "serie"; + setKind: (k: "movie" | "serie") => void; +}) => { + const { t } = useTranslation(); + + return ( + + + + } + containerClassName="flex-1" + /> + + +
+
+ ); +}; + +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({ + 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 ( + + +

+ {t("admin.add.typeToSearch")} +

+
+ ); + } + + return ( + + + } + Empty={} + Render={({ item }) => ( + { + await mutateAsync(item); + if (router.canGoBack()) router.back(); + }} + isPending={isPending} + /> + )} + Loader={SearchResultItem.Loader} + /> + + ); +}; + +AddPage.query = ( + kind: "movie" | "serie", + query: string, +): QueryIdentifier => ({ + parser: kind === "movie" ? SearchMovie : SearchSerie, + path: ["scanner", kind === "movie" ? "movies" : "series"], + params: { + query: query, + }, + infinite: true, + enabled: query.length > 0, +}); diff --git a/front/src/ui/admin/videos-modal/index.tsx b/front/src/ui/admin/videos-modal/index.tsx index 28bc856e..6e6ea3c0 100644 --- a/front/src/ui/admin/videos-modal/index.tsx +++ b/front/src/ui/admin/videos-modal/index.tsx @@ -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={ - -

{t("videos-map.no-video")}

-
- } + Empty={} Footer={} /> diff --git a/front/src/ui/unmatched/index.tsx b/front/src/ui/unmatched/index.tsx index b5b1b04b..d65cda24 100644 --- a/front/src/ui/unmatched/index.tsx +++ b/front/src/ui/unmatched/index.tsx @@ -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; @@ -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(); const map = new Map(); @@ -258,7 +259,7 @@ export const UnmatchedPage = () => { )} Loader={() => } Divider - Empty={

{t("admin.unmatched.empty")}

} + Empty={} /> ); }; @@ -273,10 +274,10 @@ UnmatchedPage.query = (search?: string): QueryIdentifier => ({ refetchInterval: 5000, }); -UnmatchedPage.scanQuery = (): QueryIdentifier => ({ - parser: z.array(ScanRequest), +UnmatchedPage.scanQuery = (): QueryIdentifier => ({ + parser: ScanRequest, path: ["scanner", "scan"], - infinite: false, + infinite: true, refetchInterval: 5000, options: { returnError: true, diff --git a/scanner/scanner/providers/thetvdb.py b/scanner/scanner/providers/thetvdb.py index 84f08bca..d9bf5c78 100644 --- a/scanner/scanner/providers/thetvdb.py +++ b/scanner/scanner/providers/thetvdb.py @@ -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: [