diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index a592bcd9..ea09262e 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -601,7 +601,7 @@ export const videosReadH = new Elysia({ tags: ["videos"] }) query ? or( sql`${videos.path} %> ${query}::text`, - sql`${videos.guess}->'title' %> ${query}::text`, + sql`${videos.guess}->>'title' %> ${query}::text`, ) : undefined, keysetPaginate({ after, sort }), diff --git a/front/public/translations/en.json b/front/public/translations/en.json index db1e0bad..d39cfdfd 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -302,6 +302,18 @@ "label": "Scanner", "scan": "Trigger library scan", "empty": "No issue found. All your items are registered." + }, + "unmatched": { + "label": "Unmatched", + "rescan": "Rescan Library", + "empty": "All videos are matched!", + "search": "Search videos...", + "status-pending": "Pending", + "status-running": "Scanning", + "status-failed": "Failed", + "progress-running": "{{count}} scanning", + "progress-pending": "{{count}} pending", + "progress-failed": "{{count}} failed" } } } diff --git a/front/src/app/(app)/unmatched.tsx b/front/src/app/(app)/unmatched.tsx new file mode 100644 index 00000000..d70fb63b --- /dev/null +++ b/front/src/app/(app)/unmatched.tsx @@ -0,0 +1,5 @@ +import { UnmatchedPage } from "~/ui/unmatched"; + +export { ErrorBoundary } from "~/ui/error-boundary"; + +export default UnmatchedPage; diff --git a/front/src/models/video.ts b/front/src/models/video.ts index 04bf917a..3634b67d 100644 --- a/front/src/models/video.ts +++ b/front/src/models/video.ts @@ -53,3 +53,21 @@ export const FullVideo = Video.extend({ show: Show.optional(), }); export type FullVideo = z.infer; + +export const ScanRequest = z.object({ + id: z.string(), + kind: z.enum(["episode", "movie"]), + title: z.string(), + year: z.int().nullable(), + status: z.enum(["pending", "running", "failed"]), + error: z + .object({ + title: z.string(), + message: z.string(), + traceback: z.array(z.string()).default([]), + }) + .nullable(), + startedAt: zdate().nullable(), + videos: z.array(z.string()).default([]), +}); +export type ScanRequest = z.infer; diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx index ca402a50..96781d46 100644 --- a/front/src/query/query.tsx +++ b/front/src/query/query.tsx @@ -141,6 +141,7 @@ export type QueryIdentifier = { placeholderData?: T | (() => T); enabled?: boolean; + refetchInterval?: number; options?: Partial[0]> & { apiUrl?: string; returnError?: boolean; @@ -190,6 +191,7 @@ export const useFetch = (query: QueryIdentifier) => { }) as Promise, placeholderData: query.placeholderData as any, enabled: query.enabled, + refetchInterval: query.refetchInterval, }); if (query.options?.returnError !== true) { @@ -251,6 +253,7 @@ export const useInfiniteFetch = (query: QueryIdentifier) => { initialPageParam: undefined, placeholderData: query.placeholderData as any, enabled: query.enabled, + refetchInterval: query.refetchInterval, }); const ret = res as typeof res & { items?: Data[] }; ret.items = ret.data?.pages.flatMap((x) => x.items); diff --git a/front/src/ui/navbar.tsx b/front/src/ui/navbar.tsx index b1bba29e..91096e95 100644 --- a/front/src/ui/navbar.tsx +++ b/front/src/ui/navbar.tsx @@ -59,6 +59,12 @@ export const NavbarLeft = () => { > {t("navbar.browse")} + + {t("admin.unmatched.label")} + ); }; diff --git a/front/src/ui/unmatched/index.tsx b/front/src/ui/unmatched/index.tsx new file mode 100644 index 00000000..0274915a --- /dev/null +++ b/front/src/ui/unmatched/index.tsx @@ -0,0 +1,289 @@ +import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg"; +import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg"; +import Play from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; +import Refresh from "@material-symbols/svg-400/rounded/refresh.svg"; +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 { ScanRequest, Video } from "~/models"; +import { + Button, + Container, + DottedSeparator, + HR, + IconButton, + Input, + Link, + Menu, + P, + Skeleton, + SubP, + tooltip, + ts, +} from "~/primitives"; +import { + InfiniteFetch, + type QueryIdentifier, + useFetch, + useMutation, +} from "~/query"; +import { cn, useQueryState } from "~/utils"; + +type VideoT = z.infer; + +const ScanStatusBadge = ({ + status, +}: { + status: "pending" | "running" | "failed" | null; +}) => { + const { t } = useTranslation(); + if (!status) return null; + + return ( + +

+ {status === "pending" && t("admin.unmatched.status-pending")} + {status === "running" && t("admin.unmatched.status-running")} + {status === "failed" && t("admin.unmatched.status-failed")} +

+
+ ); +}; + +const VideoItem = ({ + item, + scanStatus, +}: { + item: VideoT; + scanStatus: ScanRequest | null; +}) => { + const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + const episodes = item.guess.episodes; + + return ( + setMenuOpen(true)} + className="group flex-row" + > + + + + + +

{item.path}

+ + {item.guess.title} + {item.guess.kind && ( + + {item.guess.kind} + + )} + {episodes.length > 0 && ( + + {episodes + .map((x) => `S${x.season ?? "?"}E${x.episode}`) + .join(", ")} + + )} + {item.version > 1 && v{item.version}} + + {scanStatus?.status === "failed" && scanStatus.error && ( + + {scanStatus.error.message} + + )} +
+ + + + + ); +}; + +VideoItem.Loader = () => { + return ( + + + + + + + + + + + + + + ); +}; + +const ScanProgress = ({ data }: { data: ScanRequest[] | undefined }) => { + const { t } = useTranslation(); + if (!data) return null; + + const running = data.filter((x) => x.status === "running").length; + const pending = data.filter((x) => x.status === "pending").length; + const failed = data.filter((x) => x.status === "failed").length; + + if (running === 0 && pending === 0 && failed === 0) return null; + + const parts: { label: string; accent?: boolean }[] = []; + if (running > 0) + parts.push({ + label: t("admin.unmatched.progress-running", { count: running }), + accent: true, + }); + if (pending > 0) + parts.push({ + label: t("admin.unmatched.progress-pending", { count: pending }), + }); + if (failed > 0) + parts.push({ + label: t("admin.unmatched.progress-failed", { count: failed }), + }); + + return ( + + {parts.map((part, i) => ( + + {i > 0 && } + {part.label} + + ))} + + ); +}; + +const UnmatchedHeader = ({ + search, + setSearch, + scanData, +}: { + search: string; + setSearch: (q: string) => void; + scanData: ScanRequest[] | undefined; +}) => { + const { t } = useTranslation(); + const rescan = useMutation({ + method: "PUT", + path: ["scanner", "scan"], + invalidate: null, + }); + + return ( + + + } + /> + + +