From 32bfdb0ce0e57ff182ce2055bf655afa0c950365 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 10 Mar 2026 13:53:55 +0100 Subject: [PATCH] Add put routes for videos --- api/src/controllers/seed/videos.ts | 201 ++++++++++++++++---------- api/src/controllers/videos.ts | 25 +++- front/public/translations/en.json | 6 +- front/src/models/video.ts | 42 +++--- front/src/primitives/button.tsx | 7 +- front/src/primitives/combobox.tsx | 67 +++++---- front/src/primitives/combobox.web.tsx | 53 +++---- front/src/primitives/links.tsx | 10 +- front/src/query/fetch-infinite.tsx | 3 + front/src/ui/admin/videos-modal.tsx | 129 +++++++++++++---- 10 files changed, 353 insertions(+), 190 deletions(-) diff --git a/api/src/controllers/seed/videos.ts b/api/src/controllers/seed/videos.ts index 7bec4546..ba834b46 100644 --- a/api/src/controllers/seed/videos.ts +++ b/api/src/controllers/seed/videos.ts @@ -28,6 +28,91 @@ const CreatedVideo = t.Object({ ), }); +async function createVideos(body: SeedVideo[], clearLinks: boolean) { + if (body.length === 0) { + return { status: 422, message: "No videos" } as const; + } + return await db.transaction(async (tx) => { + let vids: { pk: number; id: string; path: string; guess: Guess }[] = []; + try { + vids = await tx + .insert(videos) + .select(unnestValues(body, videos)) + .onConflictDoUpdate({ + target: [videos.path], + set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), + }) + .returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + guess: videos.guess, + }); + } catch (e) { + if (!isUniqueConstraint(e)) throw e; + return { + status: 409, + message: comment` + Invalid rendering. A video with the same (rendering, part, version) combo + (but with a different path) already exists in db. + + rendering should be computed by the sha of your path (excluding only the version & part numbers) + `, + } as const; + } + + const vidEntries = body.flatMap((x) => { + if (!x.for) return []; + return x.for.map((e) => ({ + video: vids.find((v) => v.path === x.path)!.pk, + entry: { + ...e, + movie: + "movie" in e + ? isUuid(e.movie) + ? { id: e.movie } + : { slug: e.movie } + : undefined, + serie: + "serie" in e + ? isUuid(e.serie) + ? { id: e.serie } + : { slug: e.serie } + : undefined, + }, + })); + }); + + if (!vidEntries.length) { + return vids.map((x) => ({ + id: x.id, + path: x.path, + guess: x.guess, + entries: [], + })); + } + + if (clearLinks) { + await tx + .delete(entryVideoJoin) + .where( + eq( + entryVideoJoin.videoPk, + sql`any(${sqlarr(vids.map((x) => x.pk))})`, + ), + ); + } + const links = await linkVideos(tx, vidEntries); + + return vids.map((x) => ({ + id: x.id, + path: x.path, + guess: x.guess, + entries: links[x.pk] ?? [], + })); + }); +} + export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .model({ video: Video, @@ -38,84 +123,9 @@ export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .post( "", async ({ body, status }) => { - if (body.length === 0) { - return status(422, { status: 422, message: "No videos" }); - } - return await db.transaction(async (tx) => { - let vids: { pk: number; id: string; path: string; guess: Guess }[] = []; - try { - vids = await tx - .insert(videos) - .select(unnestValues(body, videos)) - .onConflictDoUpdate({ - target: [videos.path], - set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), - }) - .returning({ - pk: videos.pk, - id: videos.id, - path: videos.path, - guess: videos.guess, - }); - } catch (e) { - if (!isUniqueConstraint(e)) throw e; - return status(409, { - status: 409, - message: comment` - Invalid rendering. A video with the same (rendering, part, version) combo - (but with a different path) already exists in db. - - rendering should be computed by the sha of your path (excluding only the version & part numbers) - `, - }); - } - - const vidEntries = body.flatMap((x) => { - if (!x.for) return []; - return x.for.map((e) => ({ - video: vids.find((v) => v.path === x.path)!.pk, - entry: { - ...e, - movie: - "movie" in e - ? isUuid(e.movie) - ? { id: e.movie } - : { slug: e.movie } - : undefined, - serie: - "serie" in e - ? isUuid(e.serie) - ? { id: e.serie } - : { slug: e.serie } - : undefined, - }, - })); - }); - - if (!vidEntries.length) { - return status( - 201, - vids.map((x) => ({ - id: x.id, - path: x.path, - guess: x.guess, - entries: [], - })), - ); - } - - const links = await linkVideos(tx, vidEntries); - - return status( - 201, - vids.map((x) => ({ - id: x.id, - path: x.path, - guess: x.guess, - entries: links[x.pk] ?? [], - })), - ); - }); + const ret = await createVideos(body, false); + if ("status" in ret) return status(ret.status, ret); + return status(201, ret); }, { detail: { @@ -123,8 +133,39 @@ export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] }) Create videos in bulk. Duplicated videos will simply be ignored. - If a videos has a \`guess\` field, it will be used to automatically register the video under an existing - movie or entry. + The \`for\` field of each video can be used to link the video to an existing entry. + + If the video was already registered, links will be merged (existing and new ones will be kept). + `, + }, + body: t.Array(SeedVideo), + response: { + 201: t.Array(CreatedVideo), + 409: { + ...KError, + description: + "Invalid rendering specified. (conflicts with an existing video)", + }, + 422: KError, + }, + }, + ) + .put( + "", + async ({ body, status }) => { + const ret = await createVideos(body, true); + if ("status" in ret) return status(ret.status, ret); + return status(201, ret); + }, + { + detail: { + description: comment` + Create videos in bulk. + Duplicated videos will simply be ignored. + + The \`for\` field of each video can be used to link the video to an existing entry. + + If the video was already registered, links will be overriden (existing will be removed and new ones will be created). `, }, body: t.Array(SeedVideo), diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index dd67d3b5..93d610c6 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -11,6 +11,8 @@ import { or, type SQL, sql, + inArray, + WithSubquery, } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { Elysia, t } from "elysia"; @@ -275,6 +277,7 @@ export async function getVideos({ preferOriginal = false, relations = [], userId, + cte = [], }: { after?: string; limit: number; @@ -285,8 +288,10 @@ export async function getVideos({ preferOriginal?: boolean; relations?: (keyof typeof videoRelations)[]; userId: string; + cte?: WithSubquery[]; }) { let ret = await db + .with(...cte) .select({ ...getColumns(videos), ...buildRelations(relations, videoRelations, { @@ -620,9 +625,27 @@ export const videosReadH = new Elysia({ tags: ["videos"] }) }); } + const titleGuess = db.$with("title_guess").as( + db + .selectDistinctOn([sql`${videos.guess}->>'title'`], { + title: sql`${videos.guess}->>'title'`.as("title"), + }) + .from(videos) + .leftJoin(evJoin, eq(videos.pk, evJoin.videoPk)) + .leftJoin(entries, eq(entries.pk, evJoin.entryPk)) + .where(eq(entries.showPk, serie.pk)), + ); + const languages = processLanguages(langs); const items = await getVideos({ - filter: eq(entries.showPk, serie.pk), + cte: [titleGuess], + filter: or( + eq(entries.showPk, serie.pk), + inArray( + sql`${videos.guess}->>'title'`, + db.select().from(titleGuess), + ), + ), limit, after, query, diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 8b167e31..cc7e645b 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -22,8 +22,7 @@ "staff": "Staff", "staff-none": "The staff is unknown", "noOverview": "No overview available", - "episode-none": "There is no episodes in this season", - "episodeNoMetadata": "No metadata available", + "episode-none": "There is no episodes in this season", "episodeNoMetadata": "No metadata available", "tags": "Tags", "tags-none": "No tags available", "links": "Links", @@ -46,7 +45,8 @@ "multiVideos": "Multiples video files available", "videosCount": "{{number}} videos", "videos-map": "Edit video mappings", - "videos-map-none": "No mapping" + "videos-map-none": "NONE", + "videos-map-add": "Add another video" }, "browse": { "mediatypekey": { diff --git a/front/src/models/video.ts b/front/src/models/video.ts index 80fa5f3f..1632cceb 100644 --- a/front/src/models/video.ts +++ b/front/src/models/video.ts @@ -4,34 +4,32 @@ import { Extra } from "./extra"; import { Show } from "./show"; import { zdate } from "./utils/utils"; +export const Guess = z.looseObject({ + title: z.string(), + kind: z.enum(["episode", "movie", "extra"]).nullable().optional(), + extraKind: Extra.shape.kind.optional().nullable(), + years: z.array(z.int()).default([]), + episodes: z + .array( + z.object({ + season: z.int().nullable(), + episode: z.int(), + }), + ) + .default([]), + externalId: z.record(z.string(), z.string()).default({}), + + // Name of the tool that made the guess + from: z.string(), +}); + export const Video = z.object({ id: z.string(), path: z.string(), rendering: z.string(), part: z.int().min(0).nullable(), version: z.int().min(0).default(1), - guess: z.object({ - title: z.string(), - kind: z.enum(["episode", "movie", "extra"]).nullable().optional(), - extraKind: Extra.shape.kind.optional().nullable(), - years: z.array(z.int()).default([]), - episodes: z - .array( - z.object({ - season: z.int().nullable(), - episode: z.int(), - }), - ) - .default([]), - externalId: z.record(z.string(), z.string()).default({}), - - // Name of the tool that made the guess - from: z.string(), - // Adding that results in an infinite recursion - // get history() { - // return z.array(Video.shape.guess.omit({ history: true })).default([]); - // }, - }), + guess: Guess.extend({ history: z.array(Guess).default([]) }), createdAt: zdate(), updatedAt: zdate(), }); diff --git a/front/src/primitives/button.tsx b/front/src/primitives/button.tsx index 15aff478..d380e8b4 100644 --- a/front/src/primitives/button.tsx +++ b/front/src/primitives/button.tsx @@ -1,7 +1,6 @@ import type { ComponentProps, ComponentType, Ref } from "react"; import { type Falsy, - type Pressable, type PressableProps, View, } from "react-native"; @@ -20,18 +19,18 @@ export const Button = ({ className, ...props }: { - disabled?: boolean; + disabled?: boolean | null; text?: string; icon?: ComponentProps["icon"] | Falsy; ricon?: ComponentProps["icon"] | Falsy; - ref?: Ref; + ref?: Ref; className?: string; as?: ComponentType; } & AsProps) => { const Container = as ?? PressableFeedback; return ( = { }; type ComboBoxBaseProps = { - label: string; searchPlaceholder?: string; query: (search: string) => QueryIdentifier; getKey: (item: Data) => string; getLabel: (item: Data) => string; getSmallLabel?: (item: Data) => string; placeholderCount?: number; + label?: string; + Trigger?: ComponentType; }; export type ComboBoxProps = ComboBoxBaseProps & @@ -52,6 +59,7 @@ export const ComboBox = ({ searchPlaceholder, placeholderCount = 4, multiple, + Trigger, }: ComboBoxProps) => { const [isOpen, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -77,30 +85,34 @@ export const ComboBox = ({ return ( <> - setOpen(true)} - accessibilityLabel={label} - className={cn( - "flex-row items-center justify-center overflow-hidden", - "rounded-4xl border-3 border-accent p-1 outline-0", - "group focus-within:bg-accent hover:bg-accent", - )} - > - -

- {(multiple ? !values : !value) - ? label - : (multiple ? values : [value!]) - .sort((a, b) => getKey(a).localeCompare(getKey(b))) - .map(getSmallLabel ?? getLabel) - .join(", ")} -

- -
-
+ {Trigger ? ( + setOpen(true)} /> + ) : ( + setOpen(true)} + accessibilityLabel={label} + className={cn( + "flex-row items-center justify-center overflow-hidden", + "rounded-4xl border-3 border-accent p-1 outline-0", + "group focus-within:bg-accent hover:bg-accent", + )} + > + +

+ {(multiple ? !values?.length : !value) + ? label + : (multiple ? values : [value!]) + .sort((a, b) => getKey(a).localeCompare(getKey(b))) + .map(getSmallLabel ?? getLabel) + .join(", ")} +

+ +
+
+ )} {isOpen && ( ({ hasNextPage && !isFetching ? () => fetchNextPage() : undefined } onEndReachedThreshold={0.5} - showsVerticalScrollIndicator={false} /> diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx index 822d0e47..f2462ea9 100644 --- a/front/src/primitives/combobox.web.tsx +++ b/front/src/primitives/combobox.web.tsx @@ -26,6 +26,7 @@ export const ComboBox = ({ getSmallLabel, placeholderCount = 4, multiple, + Trigger, }: ComboBoxProps) => { const [isOpen, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -57,29 +58,33 @@ export const ComboBox = ({ }} > - - -

- {(multiple ? !values : !value) - ? label - : (multiple ? values : [value!]) - .sort((a, b) => getKey(a).localeCompare(getKey(b))) - .map(getSmallLabel ?? getLabel) - .join(", ")} -

- -
-
+ {Trigger ? ( + + ) : ( + + +

+ {(multiple ? !values.length : !value) + ? label + : (multiple ? values : [value!]) + .sort((a, b) => getKey(a).localeCompare(getKey(b))) + .map(getSmallLabel ?? getLabel) + .join(", ")} +

+ +
+
+ )}
({ hasNextPage && !isFetching ? () => fetchNextPage() : undefined } onEndReachedThreshold={0.5} - showsVerticalScrollIndicator={false} - style={{ flex: 1, overflow: "auto" as any }} /> diff --git a/front/src/primitives/links.tsx b/front/src/primitives/links.tsx index f6003a08..a1f27ce0 100644 --- a/front/src/primitives/links.tsx +++ b/front/src/primitives/links.tsx @@ -1,11 +1,12 @@ import { useRouter } from "expo-router"; -import type { ReactNode } from "react"; +import type { ReactNode, RefObject } from "react"; import { Linking, Platform, Pressable, type PressableProps, type TextProps, + type View, } from "react-native"; import { useResolveClassNames } from "uniwind"; import { cn } from "~/utils"; @@ -70,11 +71,16 @@ export const A = ({ ); }; -export const PressableFeedback = ({ children, ...props }: PressableProps) => { +export const PressableFeedback = ({ + children, + ref, + ...props +}: PressableProps & { ref?: RefObject }) => { const { color } = useResolveClassNames("text-slate-400/25"); return ( ({ Empty, Divider, Header, + Footer, fetchMore = true, contentContainerStyle, columnWrapperStyle, @@ -45,6 +46,7 @@ export const InfiniteFetch = ({ incremental?: boolean; Divider?: true | ComponentType; Header?: ComponentType<{ children: JSX.Element }> | ReactElement; + Footer?: ComponentType<{ children: JSX.Element }> | ReactElement; fetchMore?: boolean; contentContainerStyle?: ViewStyle; onScroll?: LegendListProps["onScroll"]; @@ -100,6 +102,7 @@ export const InfiniteFetch = ({ onRefresh={layout.layout !== "horizontal" ? refetch : undefined} refreshing={isRefetching} ListHeaderComponent={Header} + ListFooterComponent={Footer} ItemSeparatorComponent={ Divider === true ? HR : (Divider as any) || undefined } diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx index 967ddf4f..8915d802 100644 --- a/front/src/ui/admin/videos-modal.tsx +++ b/front/src/ui/admin/videos-modal.tsx @@ -1,8 +1,10 @@ +import Close from "@material-symbols/svg-400/rounded/close-fill.svg"; +import LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { entryDisplayNumber } from "~/components/entries"; import { Entry, FullVideo, type Page } from "~/models"; -import { ComboBox, Modal, P, Skeleton } from "~/primitives"; +import { Button, ComboBox, IconButton, Modal, P, Skeleton } from "~/primitives"; import { InfiniteFetch, type QueryIdentifier, @@ -17,7 +19,7 @@ export const VideosModal = () => { const { data } = useFetch(Header.query("serie", slug)); const { t } = useTranslation(); - const { mutateAsync } = useMutation({ + const { mutateAsync: editLinks } = useMutation({ method: "PUT", path: ["api", "videos", "link"], compute: ({ @@ -42,6 +44,53 @@ export const VideosModal = () => { })), }), }); + const { mutateAsync: editGuess } = useMutation({ + method: "PUT", + path: ["api", "videos"], + compute: (video: FullVideo) => ({ + body: [ + { + ...video, + guess: { + ...video.guess, + title: data?.name ?? slug, + from: "manual-edit", + history: [...video.guess.history, video.guess], + }, + for: video.guess.episodes.map((x) => ({ + serie: slug, + season: x.season, + episode: x.episode, + })), + entries: undefined, + }, + ], + }), + invalidate: ["api", "series", "slug"], + optimisticKey: VideosModal.query(slug), + optimistic: (params, prev?: { pages: Page[] }) => ({ + ...prev!, + pages: prev!.pages.map((p, i) => { + const idx = p.items.findIndex( + (x) => params.path.localeCompare(x.path) < 0, + ); + if (idx !== -1) { + return { + ...p, + items: [ + ...p.items.slice(0, idx), + params, + ...p.items.slice(idx, -1), + ], + }; + } + if (i === prev!.pages.length) { + return { ...p, items: [...p.items, params] }; + } + return p; + }), + }), + }); return ( @@ -51,32 +100,62 @@ export const VideosModal = () => { Render={({ item }) => (

{item.path}

- ({ - parser: Entry, - path: ["api", "series", slug, "entries"], - params: { - query: q, - }, - infinite: true, - })} - getKey={(x) => x.id} - getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`} - getSmallLabel={entryDisplayNumber} - onValueChange={async (entries) => { - await mutateAsync({ - video: item.id, - entries, - }); - }} - /> + + ({ + parser: Entry, + path: ["api", "series", slug, "entries"], + params: { + query: q, + }, + infinite: true, + })} + getKey={(x) => x.id} + getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`} + getSmallLabel={entryDisplayNumber} + onValueChange={async (entries) => { + await editLinks({ + video: item.id, + entries, + }); + }} + /> + {}} /> +
)} Loader={() => } + Footer={ + ( +