Add put routes for videos

This commit is contained in:
Zoe Roux 2026-03-10 13:53:55 +01:00
parent ef1486deaf
commit 32bfdb0ce0
No known key found for this signature in database
10 changed files with 353 additions and 190 deletions

View File

@ -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),

View File

@ -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<string>`${videos.guess}->>'title'`], {
title: sql<string>`${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<string>`${videos.guess}->>'title'`,
db.select().from(titleGuess),
),
),
limit,
after,
query,

View File

@ -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": {

View File

@ -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(),
});

View File

@ -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 = <AsProps = PressableProps>({
className,
...props
}: {
disabled?: boolean;
disabled?: boolean | null;
text?: string;
icon?: ComponentProps<typeof Icon>["icon"] | Falsy;
ricon?: ComponentProps<typeof Icon>["icon"] | Falsy;
ref?: Ref<typeof Pressable>;
ref?: Ref<View>;
className?: string;
as?: ComponentType<AsProps>;
} & AsProps) => {
const Container = as ?? PressableFeedback;
return (
<Container
ref={ref}
ref={ref as any}
disabled={disabled}
className={cn(
"flex-row items-center justify-center overflow-hidden",

View File

@ -4,8 +4,14 @@ import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import SearchIcon from "@material-symbols/svg-400/rounded/search-fill.svg";
import { useMemo, useRef, useState } from "react";
import { KeyboardAvoidingView, Pressable, TextInput, View } from "react-native";
import { type ComponentType, useMemo, useRef, useState } from "react";
import {
KeyboardAvoidingView,
Pressable,
type PressableProps,
TextInput,
View,
} from "react-native";
import { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import { Icon, IconButton } from "./icons";
@ -28,13 +34,14 @@ type ComboBoxMultiProps<Data> = {
};
type ComboBoxBaseProps<Data> = {
label: string;
searchPlaceholder?: string;
query: (search: string) => QueryIdentifier<Data>;
getKey: (item: Data) => string;
getLabel: (item: Data) => string;
getSmallLabel?: (item: Data) => string;
placeholderCount?: number;
label?: string;
Trigger?: ComponentType<PressableProps>;
};
export type ComboBoxProps<Data> = ComboBoxBaseProps<Data> &
@ -52,6 +59,7 @@ export const ComboBox = <Data,>({
searchPlaceholder,
placeholderCount = 4,
multiple,
Trigger,
}: ComboBoxProps<Data>) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
@ -77,30 +85,34 @@ export const ComboBox = <Data,>({
return (
<>
<PressableFeedback
onPressIn={() => 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",
)}
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{(multiple ? !values : !value)
? label
: (multiple ? values : [value!])
.sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</PressableFeedback>
{Trigger ? (
<Trigger onPressIn={() => setOpen(true)} />
) : (
<PressableFeedback
onPressIn={() => 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",
)}
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{(multiple ? !values?.length : !value)
? label
: (multiple ? values : [value!])
.sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</PressableFeedback>
)}
{isOpen && (
<Portal>
<Pressable
@ -179,7 +191,6 @@ export const ComboBox = <Data,>({
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
/>
</KeyboardAvoidingView>
</Portal>

View File

@ -26,6 +26,7 @@ export const ComboBox = <Data,>({
getSmallLabel,
placeholderCount = 4,
multiple,
Trigger,
}: ComboBoxProps<Data>) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
@ -57,29 +58,33 @@ export const ComboBox = <Data,>({
}}
>
<Popover.Trigger aria-label={label} asChild>
<InternalTriger
Component={Platform.OS === "web" ? "div" : PressableFeedback}
className={cn(
"group flex-row items-center justify-center overflow-hidden rounded-4xl",
"border-2 border-accent p-1 outline-0 focus-within:bg-accent hover:bg-accent",
"cursor-pointer",
)}
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{(multiple ? !values : !value)
? label
: (multiple ? values : [value!])
.sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</InternalTriger>
{Trigger ? (
<InternalTriger Component={Trigger} />
) : (
<InternalTriger
Component={Platform.OS === "web" ? "div" : PressableFeedback}
className={cn(
"group flex-row items-center justify-center overflow-hidden rounded-4xl",
"border-2 border-accent p-1 outline-0 focus-within:bg-accent hover:bg-accent",
"cursor-pointer",
)}
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{(multiple ? !values.length : !value)
? label
: (multiple ? values : [value!])
.sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</InternalTriger>
)}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
@ -145,8 +150,6 @@ export const ComboBox = <Data,>({
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
style={{ flex: 1, overflow: "auto" as any }}
/>
<Popover.Arrow className="fill-popover" />
</Popover.Content>

View File

@ -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<View> }) => {
const { color } = useResolveClassNames("text-slate-400/25");
return (
<Pressable
ref={ref}
android_ripple={{
foreground: true,
color,

View File

@ -25,6 +25,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
Empty,
Divider,
Header,
Footer,
fetchMore = true,
contentContainerStyle,
columnWrapperStyle,
@ -45,6 +46,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
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 = <Data, Type extends string = string>({
onRefresh={layout.layout !== "horizontal" ? refetch : undefined}
refreshing={isRefetching}
ListHeaderComponent={Header}
ListFooterComponent={Footer}
ItemSeparatorComponent={
Divider === true ? HR : (Divider as any) || undefined
}

View File

@ -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<FullVideo>[] }) => ({
...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 (
<Modal title={data?.name ?? t("misc.loading")} scroll={false}>
@ -51,32 +100,62 @@ export const VideosModal = () => {
Render={({ item }) => (
<View className="h-12 flex-row items-center justify-between hover:bg-card">
<P>{item.path}</P>
<ComboBox
multiple
label={t("show.videos-map-none")}
searchPlaceholder={t("navbar.search")}
values={item.entries}
query={(q) => ({
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,
});
}}
/>
<View className="flex-row">
<ComboBox
multiple
label={t("show.videos-map-none")}
searchPlaceholder={t("navbar.search")}
values={item.entries}
query={(q) => ({
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,
});
}}
/>
<IconButton icon={Close} onPress={() => {}} />
</View>
</View>
)}
Loader={() => <Skeleton />}
Footer={
<ComboBox
Trigger={(props) => (
<Button
icon={LibraryAdd}
text={t("show.videos-map-add")}
className="mt-4"
onPress={props.onPress ?? (props as any).onClick}
{...props}
/>
)}
searchPlaceholder={t("navbar.search")}
value={null}
query={(q) => ({
parser: FullVideo,
path: ["api", "videos"],
params: {
query: q,
sort: "path",
},
infinite: true,
})}
getKey={(x) => x.id}
getLabel={(x) => x.path}
onValueChange={async (x) => await editGuess(x!)}
/>
}
/>
</Modal>
);