mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-17 15:09:15 -04:00
Add put routes for videos
This commit is contained in:
parent
ef1486deaf
commit
32bfdb0ce0
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user