mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-17 15:09:15 -04:00
Implement admin video mapper (#1350)
This commit is contained in:
commit
f4d3e86735
@ -8,13 +8,16 @@ import { nextup } from "./controllers/profiles/nextup";
|
||||
import { watchlistH } from "./controllers/profiles/watchlist";
|
||||
import { seasonsH } from "./controllers/seasons";
|
||||
import { seed } from "./controllers/seed";
|
||||
import { videoLinkH } from "./controllers/seed/video-links";
|
||||
import { videosWriteH } from "./controllers/seed/videos";
|
||||
import { collections } from "./controllers/shows/collections";
|
||||
import { movies } from "./controllers/shows/movies";
|
||||
import { series } from "./controllers/shows/series";
|
||||
import { showsH } from "./controllers/shows/shows";
|
||||
import { staffH } from "./controllers/staff";
|
||||
import { studiosH } from "./controllers/studios";
|
||||
import { videosReadH, videosWriteH } from "./controllers/videos";
|
||||
import { videosMetadata } from "./controllers/video-metadata";
|
||||
import { videosReadH } from "./controllers/videos";
|
||||
import { dbRaw } from "./db";
|
||||
import { KError } from "./models/error";
|
||||
import { appWs } from "./websockets";
|
||||
@ -124,7 +127,8 @@ export const handlers = new Elysia({ prefix })
|
||||
.use(watchlistH)
|
||||
.use(historyH)
|
||||
.use(nextup)
|
||||
.use(videosReadH),
|
||||
.use(videosReadH)
|
||||
.use(videosMetadata),
|
||||
)
|
||||
.guard(
|
||||
{
|
||||
@ -137,5 +141,5 @@ export const handlers = new Elysia({ prefix })
|
||||
},
|
||||
permissions: ["core.write"],
|
||||
},
|
||||
(app) => app.use(videosWriteH).use(seed),
|
||||
(app) => app.use(videosWriteH).use(videoLinkH).use(seed),
|
||||
);
|
||||
|
||||
314
api/src/controllers/seed/video-links.ts
Normal file
314
api/src/controllers/seed/video-links.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import { and, eq, gt, ne, or, sql } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { auth } from "~/auth";
|
||||
import { db, type Transaction } from "~/db";
|
||||
import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
|
||||
import { sqlarr, unnest } from "~/db/utils";
|
||||
import { bubble } from "~/models/examples";
|
||||
import { isUuid } from "~/models/utils";
|
||||
import { SeedVideo } from "~/models/video";
|
||||
import { computeVideoSlug } from "./insert/entries";
|
||||
import { updateAvailableCount, updateAvailableSince } from "./insert/shows";
|
||||
|
||||
const LinkReq = t.Array(
|
||||
t.Object({
|
||||
id: t.String({
|
||||
description: "Id of the video",
|
||||
format: "uuid",
|
||||
}),
|
||||
for: t.Array(SeedVideo.properties.for.items),
|
||||
}),
|
||||
);
|
||||
type LinkReq = typeof LinkReq.static;
|
||||
|
||||
const LinkRet = t.Array(
|
||||
t.Object({
|
||||
id: t.String({ format: "uuid" }),
|
||||
path: t.String({ examples: ["/video/made in abyss s1e13.mkv"] }),
|
||||
entries: t.Array(
|
||||
t.Object({
|
||||
slug: t.String({
|
||||
format: "slug",
|
||||
examples: ["made-in-abyss-s1e13"],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
type LinkRet = typeof LinkRet.static;
|
||||
|
||||
async function mapBody(tx: Transaction, body: LinkReq) {
|
||||
const vids = await tx
|
||||
.select({ pk: videos.pk, id: videos.id, path: videos.path })
|
||||
.from(videos)
|
||||
.where(eq(videos.id, sql`any(${sqlarr(body.map((x) => x.id))})`));
|
||||
const mapped = body.flatMap((x) =>
|
||||
x.for.map((e) => ({
|
||||
video: vids.find((v) => v.id === x.id)!.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,
|
||||
},
|
||||
})),
|
||||
);
|
||||
return [vids, mapped] as const;
|
||||
}
|
||||
|
||||
export async function linkVideos(
|
||||
tx: Transaction,
|
||||
links: {
|
||||
video: number;
|
||||
entry: Omit<SeedVideo["for"], "movie" | "serie"> & {
|
||||
movie?: { id?: string; slug?: string };
|
||||
serie?: { id?: string; slug?: string };
|
||||
};
|
||||
}[],
|
||||
) {
|
||||
if (!links.length) return {};
|
||||
|
||||
const entriesQ = tx
|
||||
.select({
|
||||
pk: entries.pk,
|
||||
id: entries.id,
|
||||
slug: entries.slug,
|
||||
kind: entries.kind,
|
||||
seasonNumber: entries.seasonNumber,
|
||||
episodeNumber: entries.episodeNumber,
|
||||
order: entries.order,
|
||||
showId: sql`${shows.id}`.as("showId"),
|
||||
showSlug: sql`${shows.slug}`.as("showSlug"),
|
||||
externalId: entries.externalId,
|
||||
})
|
||||
.from(entries)
|
||||
.innerJoin(shows, eq(entries.showPk, shows.pk))
|
||||
.as("entriesQ");
|
||||
|
||||
const renderVid = alias(videos, "renderVid");
|
||||
const hasRenderingQ = or(
|
||||
gt(
|
||||
sql`dense_rank() over (partition by ${entriesQ.pk} order by ${videos.rendering})`,
|
||||
1,
|
||||
),
|
||||
sql`exists(${tx
|
||||
.select()
|
||||
.from(entryVideoJoin)
|
||||
.innerJoin(renderVid, eq(renderVid.pk, entryVideoJoin.videoPk))
|
||||
.where(
|
||||
and(
|
||||
eq(entryVideoJoin.entryPk, entriesQ.pk),
|
||||
ne(renderVid.rendering, videos.rendering),
|
||||
),
|
||||
)})`,
|
||||
)!;
|
||||
|
||||
const ret = await tx
|
||||
.insert(entryVideoJoin)
|
||||
.select(
|
||||
tx
|
||||
.selectDistinctOn([entriesQ.pk, videos.pk], {
|
||||
entryPk: entriesQ.pk,
|
||||
videoPk: videos.pk,
|
||||
slug: computeVideoSlug(entriesQ.slug, hasRenderingQ),
|
||||
})
|
||||
.from(
|
||||
unnest(links, "j", {
|
||||
video: "integer",
|
||||
entry: "jsonb",
|
||||
}),
|
||||
)
|
||||
.innerJoin(videos, eq(videos.pk, sql`j.video`))
|
||||
.innerJoin(
|
||||
entriesQ,
|
||||
or(
|
||||
and(
|
||||
sql`j.entry ? 'slug'`,
|
||||
eq(entriesQ.slug, sql`j.entry->>'slug'`),
|
||||
),
|
||||
and(
|
||||
sql`j.entry ? 'movie'`,
|
||||
or(
|
||||
eq(entriesQ.showId, sql`(j.entry #>> '{movie, id}')::uuid`),
|
||||
eq(entriesQ.showSlug, sql`j.entry #>> '{movie, slug}'`),
|
||||
),
|
||||
eq(entriesQ.kind, "movie"),
|
||||
),
|
||||
and(
|
||||
sql`j.entry ? 'serie'`,
|
||||
or(
|
||||
eq(entriesQ.showId, sql`(j.entry #>> '{serie, id}')::uuid`),
|
||||
eq(entriesQ.showSlug, sql`j.entry #>> '{serie, slug}'`),
|
||||
),
|
||||
or(
|
||||
and(
|
||||
sql`j.entry ?& array['season', 'episode']`,
|
||||
eq(entriesQ.seasonNumber, sql`(j.entry->>'season')::integer`),
|
||||
eq(
|
||||
entriesQ.episodeNumber,
|
||||
sql`(j.entry->>'episode')::integer`,
|
||||
),
|
||||
),
|
||||
and(
|
||||
sql`j.entry ? 'order'`,
|
||||
eq(entriesQ.order, sql`(j.entry->>'order')::float`),
|
||||
),
|
||||
and(
|
||||
sql`j.entry ? 'special'`,
|
||||
eq(
|
||||
entriesQ.episodeNumber,
|
||||
sql`(j.entry->>'special')::integer`,
|
||||
),
|
||||
eq(entriesQ.kind, "special"),
|
||||
),
|
||||
),
|
||||
),
|
||||
and(
|
||||
sql`j.entry ? 'externalId'`,
|
||||
sql`j.entry->'externalId' <@ ${entriesQ.externalId}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [entryVideoJoin.entryPk, entryVideoJoin.videoPk],
|
||||
// this is basically a `.onConflictDoNothing()` but we want `returning` to give us the existing data
|
||||
set: { entryPk: sql`excluded.entry_pk` },
|
||||
})
|
||||
.returning({
|
||||
slug: entryVideoJoin.slug,
|
||||
entryPk: entryVideoJoin.entryPk,
|
||||
videoPk: entryVideoJoin.videoPk,
|
||||
});
|
||||
|
||||
const entr = ret.reduce(
|
||||
(acc, x) => {
|
||||
acc[x.videoPk] ??= [];
|
||||
acc[x.videoPk].push({ slug: x.slug });
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, { slug: string }[]>,
|
||||
);
|
||||
|
||||
const entriesPk = [...new Set(ret.map((x) => x.entryPk))];
|
||||
await updateAvailableCount(
|
||||
tx,
|
||||
tx
|
||||
.selectDistinct({ pk: entries.showPk })
|
||||
.from(entries)
|
||||
.where(eq(entries.pk, sql`any(${sqlarr(entriesPk)})`)),
|
||||
);
|
||||
await updateAvailableSince(tx, entriesPk);
|
||||
|
||||
return entr;
|
||||
}
|
||||
|
||||
export const videoLinkH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
.use(auth)
|
||||
.post(
|
||||
"/link",
|
||||
async ({ body, status }) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [vids, mapped] = await mapBody(tx, body);
|
||||
const links = await linkVideos(tx, mapped);
|
||||
return status(
|
||||
201,
|
||||
vids.map((x) => ({
|
||||
id: x.id,
|
||||
path: x.path,
|
||||
entries: links[x.pk] ?? [],
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Link existing videos to existing entries",
|
||||
},
|
||||
body: LinkReq,
|
||||
response: {
|
||||
201: LinkRet,
|
||||
},
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/link",
|
||||
async ({ body, status }) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [vids, mapped] = await mapBody(tx, body);
|
||||
await tx
|
||||
.delete(entryVideoJoin)
|
||||
.where(
|
||||
eq(
|
||||
entryVideoJoin.videoPk,
|
||||
sql`any(${sqlarr(vids.map((x) => x.pk))})`,
|
||||
),
|
||||
);
|
||||
const links = await linkVideos(tx, mapped);
|
||||
|
||||
return status(
|
||||
201,
|
||||
vids.map((x) => ({
|
||||
id: x.id,
|
||||
path: x.path,
|
||||
entries: links[x.pk] ?? [],
|
||||
})),
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description:
|
||||
"Override all links between the specified videos and entries.",
|
||||
},
|
||||
body: LinkReq,
|
||||
response: {
|
||||
201: LinkRet,
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/link",
|
||||
async ({ body }) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const ret = await tx
|
||||
.delete(entryVideoJoin)
|
||||
.where(eq(entryVideoJoin.slug, sql`any(${sqlarr(body)})`))
|
||||
.returning({
|
||||
slug: entryVideoJoin.slug,
|
||||
entryPk: entryVideoJoin.entryPk,
|
||||
});
|
||||
|
||||
const entriesPk = [...new Set(ret.map((x) => x.entryPk))];
|
||||
await updateAvailableCount(
|
||||
tx,
|
||||
tx
|
||||
.selectDistinct({ pk: entries.showPk })
|
||||
.from(entries)
|
||||
.where(eq(entries.pk, sql`any(${sqlarr(entriesPk)})`)),
|
||||
);
|
||||
await updateAvailableSince(tx, entriesPk);
|
||||
|
||||
return ret.map((x) => x.slug);
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Delete links between an entry and a video by their slug",
|
||||
},
|
||||
body: t.Array(t.String({ format: "slug", examples: [bubble.slug] })),
|
||||
response: {
|
||||
200: t.Array(t.String({ format: "slug", examples: [bubble.slug] })),
|
||||
},
|
||||
},
|
||||
);
|
||||
240
api/src/controllers/seed/videos.ts
Normal file
240
api/src/controllers/seed/videos.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import { and, eq, notExists, sql } from "drizzle-orm";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { auth } from "~/auth";
|
||||
import { db } from "~/db";
|
||||
import { entries, entryVideoJoin, videos } from "~/db/schema";
|
||||
import {
|
||||
conflictUpdateAllExcept,
|
||||
isUniqueConstraint,
|
||||
sqlarr,
|
||||
unnestValues,
|
||||
} from "~/db/utils";
|
||||
import { KError } from "~/models/error";
|
||||
import { bubbleVideo } from "~/models/examples";
|
||||
import { isUuid } from "~/models/utils";
|
||||
import { Guess, SeedVideo, Video } from "~/models/video";
|
||||
import { comment } from "~/utils";
|
||||
import { updateAvailableCount } from "./insert/shows";
|
||||
import { linkVideos } from "./video-links";
|
||||
|
||||
const CreatedVideo = t.Object({
|
||||
id: t.String({ format: "uuid" }),
|
||||
path: t.String({ examples: [bubbleVideo.path] }),
|
||||
guess: t.Omit(Guess, ["history"]),
|
||||
entries: t.Array(
|
||||
t.Object({
|
||||
slug: t.String({ format: "slug", examples: ["bubble-v2"] }),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
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,
|
||||
"created-videos": t.Array(CreatedVideo),
|
||||
error: t.Object({}),
|
||||
})
|
||||
.use(auth)
|
||||
.post(
|
||||
"",
|
||||
async ({ body, status }) => {
|
||||
const ret = await createVideos(body, false);
|
||||
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 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),
|
||||
response: {
|
||||
201: t.Array(CreatedVideo),
|
||||
409: {
|
||||
...KError,
|
||||
description:
|
||||
"Invalid rendering specified. (conflicts with an existing video)",
|
||||
},
|
||||
422: KError,
|
||||
},
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"",
|
||||
async ({ body }) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const vids = tx.$with("vids").as(
|
||||
tx
|
||||
.delete(videos)
|
||||
.where(eq(videos.path, sql`any(${sqlarr(body)})`))
|
||||
.returning({ pk: videos.pk, path: videos.path }),
|
||||
);
|
||||
|
||||
const deletedJoin = await tx
|
||||
.with(vids)
|
||||
.select({ entryPk: entryVideoJoin.entryPk, path: vids.path })
|
||||
.from(entryVideoJoin)
|
||||
.rightJoin(vids, eq(vids.pk, entryVideoJoin.videoPk));
|
||||
|
||||
const delEntries = await tx
|
||||
.update(entries)
|
||||
.set({ availableSince: null })
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
entries.pk,
|
||||
sql`any(${sqlarr(
|
||||
deletedJoin.filter((x) => x.entryPk).map((x) => x.entryPk!),
|
||||
)})`,
|
||||
),
|
||||
notExists(
|
||||
tx
|
||||
.select()
|
||||
.from(entryVideoJoin)
|
||||
.where(eq(entries.pk, entryVideoJoin.entryPk)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ show: entries.showPk });
|
||||
|
||||
await updateAvailableCount(
|
||||
tx,
|
||||
delEntries.map((x) => x.show),
|
||||
false,
|
||||
);
|
||||
|
||||
return [...new Set(deletedJoin.map((x) => x.path))];
|
||||
});
|
||||
},
|
||||
{
|
||||
detail: { description: "Delete videos in bulk." },
|
||||
body: t.Array(
|
||||
t.String({
|
||||
description: "Path of the video to delete",
|
||||
examples: [bubbleVideo.path],
|
||||
}),
|
||||
),
|
||||
response: { 200: t.Array(t.String()) },
|
||||
},
|
||||
);
|
||||
188
api/src/controllers/video-metadata.ts
Normal file
188
api/src/controllers/video-metadata.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Elysia, t } from "elysia";
|
||||
import { auth } from "~/auth";
|
||||
import { db } from "~/db";
|
||||
import { entryVideoJoin, videos } from "~/db/schema";
|
||||
import { KError } from "~/models/error";
|
||||
import { isUuid } from "~/models/utils";
|
||||
import { Video } from "~/models/video";
|
||||
|
||||
export const videosMetadata = new Elysia({
|
||||
prefix: "/videos",
|
||||
tags: ["videos"],
|
||||
})
|
||||
.model({
|
||||
video: Video,
|
||||
error: t.Object({}),
|
||||
})
|
||||
.use(auth)
|
||||
.get(
|
||||
":id/info",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
return redirect(`/video/${path}/info`);
|
||||
},
|
||||
{
|
||||
detail: { description: "Get a video's metadata informations" },
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to retrieve.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/info](?api=transcoder#tag/metadata/get/:path/info) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/thumbnails.vtt",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
return redirect(`/video/${path}/thumbnails.vtt`);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Get redirected to the direct stream of the video",
|
||||
},
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/direct",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
const filename = path.substring(path.lastIndexOf("/") + 1);
|
||||
return redirect(`/video/${path}/direct/${filename}`);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Get redirected to the direct stream of the video",
|
||||
},
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/master.m3u8",
|
||||
async ({ params: { id }, request, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
const query = request.url.substring(request.url.indexOf("?"));
|
||||
return redirect(`/video/${path}/master.m3u8${query}`);
|
||||
},
|
||||
{
|
||||
detail: { description: "Get redirected to the master.m3u8 of the video" },
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/master.m3u8](?api=transcoder#tag/metadata/get/:path/master.m3u8) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
primaryKey,
|
||||
@ -52,7 +53,10 @@ export const entryVideoJoin = schema.table(
|
||||
.references(() => videos.pk, { onDelete: "cascade" }),
|
||||
slug: varchar({ length: 255 }).notNull().unique(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.entryPk, t.videoPk] })],
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.entryPk, t.videoPk] }),
|
||||
index("evj_video_pk").on(t.videoPk),
|
||||
],
|
||||
);
|
||||
|
||||
export const videosRelations = relations(videos, ({ many }) => ({
|
||||
|
||||
46
api/src/models/full-video.ts
Normal file
46
api/src/models/full-video.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { t } from "elysia";
|
||||
import { Entry } from "./entry";
|
||||
import { Progress } from "./history";
|
||||
import { Movie } from "./movie";
|
||||
import { Serie } from "./serie";
|
||||
import { Video } from "./video";
|
||||
|
||||
export const FullVideo = t.Composite([
|
||||
Video,
|
||||
t.Object({
|
||||
slugs: t.Array(
|
||||
t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }),
|
||||
),
|
||||
progress: t.Optional(Progress),
|
||||
entries: t.Array(t.Omit(Entry, ["videos", "progress"])),
|
||||
previous: t.Optional(
|
||||
t.Nullable(
|
||||
t.Object({
|
||||
video: t.String({
|
||||
format: "slug",
|
||||
examples: ["made-in-abyss-s1e12"],
|
||||
}),
|
||||
entry: Entry,
|
||||
}),
|
||||
),
|
||||
),
|
||||
next: t.Optional(
|
||||
t.Nullable(
|
||||
t.Object({
|
||||
video: t.String({
|
||||
format: "slug",
|
||||
examples: ["made-in-abyss-dawn-of-the-deep-soul"],
|
||||
}),
|
||||
entry: Entry,
|
||||
}),
|
||||
),
|
||||
),
|
||||
show: t.Optional(
|
||||
t.Union([
|
||||
t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]),
|
||||
t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
export type FullVideo = typeof FullVideo.static;
|
||||
@ -13,20 +13,20 @@ export type Sort = {
|
||||
random?: { seed: number };
|
||||
};
|
||||
|
||||
export type SortVal =
|
||||
| PgColumn
|
||||
| {
|
||||
sql: PgColumn;
|
||||
accessor: (cursor: any) => unknown;
|
||||
}
|
||||
| {
|
||||
sql: SQLWrapper;
|
||||
isNullable: boolean;
|
||||
accessor: (cursor: any) => unknown;
|
||||
};
|
||||
|
||||
export const Sort = (
|
||||
values: Record<
|
||||
string,
|
||||
| PgColumn
|
||||
| {
|
||||
sql: PgColumn;
|
||||
accessor: (cursor: any) => unknown;
|
||||
}
|
||||
| {
|
||||
sql: SQLWrapper;
|
||||
isNullable: boolean;
|
||||
accessor: (cursor: any) => unknown;
|
||||
}
|
||||
>,
|
||||
values: Record<string, SortVal | SortVal[]>,
|
||||
{
|
||||
description = "How to sort the query",
|
||||
default: def,
|
||||
@ -65,26 +65,29 @@ export const Sort = (
|
||||
}
|
||||
return {
|
||||
tablePk,
|
||||
sort: sort.map((x) => {
|
||||
sort: sort.flatMap((x) => {
|
||||
const desc = x[0] === "-";
|
||||
const key = desc ? x.substring(1) : x;
|
||||
if ("getSQL" in values[key]) {
|
||||
const process = (val: SortVal): Sort["sort"][0] => {
|
||||
if ("getSQL" in val) {
|
||||
return {
|
||||
sql: val,
|
||||
isNullable: !val.notNull,
|
||||
accessor: (x) => x[key],
|
||||
desc,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sql: values[key],
|
||||
isNullable: !values[key].notNull,
|
||||
accessor: (x) => x[key],
|
||||
sql: val.sql,
|
||||
isNullable:
|
||||
"isNullable" in val ? val.isNullable : !val.sql.notNull,
|
||||
accessor: val.accessor,
|
||||
desc,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sql: values[key].sql,
|
||||
isNullable:
|
||||
"isNullable" in values[key]
|
||||
? values[key].isNullable
|
||||
: !values[key].sql.notNull,
|
||||
accessor: values[key].accessor,
|
||||
desc,
|
||||
};
|
||||
return Array.isArray(values[key])
|
||||
? values[key].map(process)
|
||||
: process(values[key]);
|
||||
}),
|
||||
};
|
||||
})
|
||||
|
||||
@ -33,7 +33,7 @@ export function uniq<T>(a: T[]): T[] {
|
||||
return uniqBy(a, (x) => x as string);
|
||||
}
|
||||
|
||||
export function uniqBy<T>(a: T[], key: (val: T) => string): T[] {
|
||||
export function uniqBy<T>(a: T[], key: (val: T) => string | number): T[] {
|
||||
const seen: Record<string, boolean> = {};
|
||||
return a.filter((item) => {
|
||||
const k = key(item);
|
||||
|
||||
@ -18,9 +18,9 @@ export const createVideo = async (video: SeedVideo | SeedVideo[]) => {
|
||||
return [resp, body] as const;
|
||||
};
|
||||
|
||||
export const getVideos = async () => {
|
||||
export const getGuesses = async () => {
|
||||
const resp = await handlers.handle(
|
||||
new Request(buildUrl("videos"), {
|
||||
new Request(buildUrl("videos/guesses"), {
|
||||
method: "GET",
|
||||
headers: await getJwtHeaders(),
|
||||
}),
|
||||
|
||||
@ -2,7 +2,7 @@ import { db, migrate } from "~/db";
|
||||
import { profiles, shows } from "~/db/schema";
|
||||
import { bubble, madeInAbyss } from "~/models/examples";
|
||||
import { setupLogging } from "../src/logtape";
|
||||
import { createMovie, createSerie, createVideo, getVideos } from "./helpers";
|
||||
import { createMovie, createSerie, createVideo, getGuesses } from "./helpers";
|
||||
|
||||
// test file used to run manually using `bun tests/manual.ts`
|
||||
// run those before running this script
|
||||
@ -54,7 +54,7 @@ const [resp, body] = await createVideo([
|
||||
},
|
||||
]);
|
||||
console.log(body);
|
||||
const [___, ret] = await getVideos();
|
||||
const [___, ret] = await getGuesses();
|
||||
console.log(JSON.stringify(ret, undefined, 4));
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
createSerie,
|
||||
createVideo,
|
||||
deleteVideo,
|
||||
getVideos,
|
||||
getGuesses,
|
||||
linkVideos,
|
||||
} from "tests/helpers";
|
||||
import { expectStatus } from "tests/utils";
|
||||
@ -108,7 +108,7 @@ beforeAll(async () => {
|
||||
|
||||
describe("Video get/deletion", () => {
|
||||
it("Get current state", async () => {
|
||||
const [resp, body] = await getVideos();
|
||||
const [resp, body] = await getGuesses();
|
||||
expectStatus(resp, body).toBe(200);
|
||||
expect(body.guesses).toMatchObject({
|
||||
mia: {
|
||||
@ -145,7 +145,7 @@ describe("Video get/deletion", () => {
|
||||
});
|
||||
expectStatus(resp, body).toBe(201);
|
||||
|
||||
[resp, body] = await getVideos();
|
||||
[resp, body] = await getGuesses();
|
||||
expectStatus(resp, body).toBe(200);
|
||||
expect(body.guesses).toMatchObject({
|
||||
mia: {
|
||||
@ -187,7 +187,7 @@ describe("Video get/deletion", () => {
|
||||
});
|
||||
expectStatus(resp, body).toBe(201);
|
||||
|
||||
[resp, body] = await getVideos();
|
||||
[resp, body] = await getGuesses();
|
||||
expectStatus(resp, body).toBe(200);
|
||||
expect(body.guesses).toMatchObject({
|
||||
mia: {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
15
biome.json
15
biome.json
@ -28,7 +28,8 @@
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noTemplateCurlyInString": "off"
|
||||
"noTemplateCurlyInString": "off",
|
||||
"noDuplicateCustomProperties": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
@ -44,16 +45,8 @@
|
||||
"useSortedClasses": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"attributes": [
|
||||
"classList"
|
||||
],
|
||||
"functions": [
|
||||
"clsx",
|
||||
"cva",
|
||||
"cn",
|
||||
"tw",
|
||||
"tw.*"
|
||||
]
|
||||
"attributes": ["classList"],
|
||||
"functions": ["clsx", "cva", "cn", "tw", "tw.*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"@legendapp/list": "zoriya/legend-list#build",
|
||||
"@material-symbols/svg-400": "^0.40.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
@ -474,6 +475,8 @@
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"@legendapp/list": "zoriya/legend-list#build",
|
||||
"@material-symbols/svg-400": "^0.40.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
|
||||
@ -44,7 +44,18 @@
|
||||
"nextUp": "Next up",
|
||||
"season": "Season {{number}}",
|
||||
"multiVideos": "Multiples video files available",
|
||||
"videosCount": "{{number}} videos"
|
||||
"videosCount": "{{number}} videos",
|
||||
"videos-map": "Edit video mappings"
|
||||
},
|
||||
"videos-map": {
|
||||
"none": "NONE",
|
||||
"add": "Add another video",
|
||||
"delete": "Remove video from serie",
|
||||
"validate": "Validate guess and add video to serie",
|
||||
"no-guess": "This video was guessed to be part of this serie but we don't know which episode",
|
||||
"related": "Added videos related to the title {{title}}",
|
||||
"sort-path": "Path",
|
||||
"sort-entry": "Episode order"
|
||||
},
|
||||
"browse": {
|
||||
"mediatypekey": {
|
||||
|
||||
@ -28,17 +28,6 @@ export default function Layout() {
|
||||
},
|
||||
headerTintColor: color as string,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="info/[slug]"
|
||||
options={{
|
||||
presentation: "transparentModal",
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
3
front/src/app/(app)/series/[slug]/videos.tsx
Normal file
3
front/src/app/(app)/series/[slug]/videos.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { VideosModal } from "~/ui/admin";
|
||||
|
||||
export default VideosModal;
|
||||
@ -3,7 +3,7 @@ import type { Entry } from "~/models";
|
||||
export * from "./entry-box";
|
||||
export * from "./entry-line";
|
||||
|
||||
export const entryDisplayNumber = (entry: Entry) => {
|
||||
export const entryDisplayNumber = (entry: Partial<Entry>) => {
|
||||
switch (entry.kind) {
|
||||
case "episode":
|
||||
return `S${entry.seasonNumber}:E${entry.episodeNumber}`;
|
||||
|
||||
@ -1,50 +1,55 @@
|
||||
import { z } from "zod/v4";
|
||||
import { Entry } from "./entry";
|
||||
import { Entry, Episode, MovieEntry, Special } from "./entry";
|
||||
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(),
|
||||
});
|
||||
|
||||
export const FullVideo = Video.extend({
|
||||
slugs: z.array(z.string()),
|
||||
progress: z.object({
|
||||
percent: z.int().min(0).max(100),
|
||||
time: z.int().min(0),
|
||||
playedDate: zdate().nullable(),
|
||||
videoId: z.string().nullable(),
|
||||
}),
|
||||
entries: z.array(Entry),
|
||||
entries: z.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
Episode.omit({ progress: true, videos: true }),
|
||||
MovieEntry.omit({ progress: true, videos: true }),
|
||||
Special.omit({ progress: true, videos: true }),
|
||||
]),
|
||||
),
|
||||
progress: z.optional(
|
||||
z.object({
|
||||
percent: z.int().min(0).max(100),
|
||||
time: z.int().min(0),
|
||||
playedDate: zdate().nullable(),
|
||||
videoId: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
previous: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
|
||||
next: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
|
||||
show: Show.optional(),
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
import type { ComponentProps, ComponentType, Ref } from "react";
|
||||
import {
|
||||
type Falsy,
|
||||
type Pressable,
|
||||
type PressableProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { type Falsy, type PressableProps, View } from "react-native";
|
||||
import { cn } from "~/utils";
|
||||
import { Icon } from "./icons";
|
||||
import { PressableFeedback } from "./links";
|
||||
@ -20,18 +15,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",
|
||||
|
||||
235
front/src/primitives/combobox.tsx
Normal file
235
front/src/primitives/combobox.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { LegendList } from "@legendapp/list";
|
||||
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 { 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";
|
||||
import { PressableFeedback } from "./links";
|
||||
import { Skeleton } from "./skeleton";
|
||||
import { P } from "./text";
|
||||
|
||||
type ComboBoxSingleProps<Data> = {
|
||||
multiple?: false;
|
||||
value: Data | null;
|
||||
values?: never;
|
||||
onValueChange: (item: Data | null) => void;
|
||||
};
|
||||
|
||||
type ComboBoxMultiProps<Data> = {
|
||||
multiple: true;
|
||||
value?: never;
|
||||
values: Data[];
|
||||
onValueChange: (items: Data[]) => void;
|
||||
};
|
||||
|
||||
type ComboBoxBaseProps<Data> = {
|
||||
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> &
|
||||
(ComboBoxSingleProps<Data> | ComboBoxMultiProps<Data>);
|
||||
|
||||
export const ComboBox = <Data,>({
|
||||
label,
|
||||
value,
|
||||
values,
|
||||
onValueChange,
|
||||
query,
|
||||
getLabel,
|
||||
getSmallLabel,
|
||||
getKey,
|
||||
searchPlaceholder,
|
||||
placeholderCount = 4,
|
||||
multiple,
|
||||
Trigger,
|
||||
}: ComboBoxProps<Data>) => {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
const oldItems = useRef<Data[] | undefined>(undefined);
|
||||
let { items, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(
|
||||
query(search),
|
||||
);
|
||||
if (items) oldItems.current = items;
|
||||
items ??= oldItems.current;
|
||||
|
||||
const data = useMemo(() => {
|
||||
const placeholders = [...Array(placeholderCount)].fill(null);
|
||||
if (!items) return placeholders;
|
||||
return isFetching ? [...items, ...placeholders] : items;
|
||||
}, [items, isFetching, placeholderCount]);
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
if (multiple) return new Set(values.map(getKey));
|
||||
return new Set(value !== null ? [getKey(value)] : []);
|
||||
}, [value, values, multiple, getKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 flex-1 bg-transparent"
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
className={cn(
|
||||
"absolute bottom-0 w-full self-center bg-popover pb-safe sm:mx-12 sm:max-w-2xl",
|
||||
"mt-20 max-h-[80vh] rounded-t-4xl pt-8",
|
||||
"xl:top-0 xl:right-0 xl:mr-0 xl:rounded-l-4xl xl:rounded-tr-0",
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
icon={Close}
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="hidden self-end xl:flex"
|
||||
/>
|
||||
<View
|
||||
className={cn(
|
||||
"mx-4 mb-2 flex-row items-center rounded-xl border border-accent p-1",
|
||||
"focus-within:border-2",
|
||||
)}
|
||||
>
|
||||
<Icon icon={SearchIcon} className="mx-2" />
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus
|
||||
textAlignVertical="center"
|
||||
className="h-full flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400"
|
||||
/>
|
||||
</View>
|
||||
<LegendList
|
||||
data={data}
|
||||
estimatedItemSize={48}
|
||||
keyExtractor={(item: Data | null, index: number) =>
|
||||
item ? getKey(item) : `placeholder-${index}`
|
||||
}
|
||||
renderItem={({ item }: { item: Data | null }) =>
|
||||
item ? (
|
||||
<ComboBoxItem
|
||||
label={getLabel(item)}
|
||||
selected={selectedKeys.has(getKey(item))}
|
||||
onSelect={() => {
|
||||
if (!multiple) {
|
||||
onValueChange(item);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedKeys.has(getKey(item))) {
|
||||
onValueChange([...values, item]);
|
||||
return;
|
||||
}
|
||||
onValueChange(
|
||||
values.filter((v) => getKey(v) !== getKey(item)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ComboBoxItemLoader />
|
||||
)
|
||||
}
|
||||
onEndReached={
|
||||
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
|
||||
}
|
||||
onEndReachedThreshold={0.5}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ComboBoxItem = ({
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<PressableFeedback
|
||||
onPress={onSelect}
|
||||
className="h-12 w-full flex-row items-center px-4"
|
||||
>
|
||||
{selected && <Icon icon={Check} className="mx-6" />}
|
||||
<P
|
||||
style={{
|
||||
paddingLeft: selected ? 0 : 8 * 2 + 24,
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
{label}
|
||||
</P>
|
||||
</PressableFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
const ComboBoxItemLoader = () => {
|
||||
return (
|
||||
<View className="h-12 w-full flex-row items-center px-4">
|
||||
<Skeleton className="ml-14 h-4 w-3/5" />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
201
front/src/primitives/combobox.web.tsx
Normal file
201
front/src/primitives/combobox.web.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import { LegendList } from "@legendapp/list";
|
||||
import Check from "@material-symbols/svg-400/rounded/check-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 * as Popover from "@radix-ui/react-popover";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useInfiniteFetch } from "~/query/query";
|
||||
import { cn } from "~/utils";
|
||||
import type { ComboBoxProps } from "./combobox";
|
||||
import { Icon } from "./icons";
|
||||
import { PressableFeedback } from "./links";
|
||||
import { InternalTriger } from "./menu.web";
|
||||
import { Skeleton } from "./skeleton";
|
||||
import { P } from "./text";
|
||||
|
||||
export const ComboBox = <Data,>({
|
||||
label,
|
||||
searchPlaceholder,
|
||||
value,
|
||||
values,
|
||||
onValueChange,
|
||||
query,
|
||||
getKey,
|
||||
getLabel,
|
||||
getSmallLabel,
|
||||
placeholderCount = 4,
|
||||
multiple,
|
||||
Trigger,
|
||||
}: ComboBoxProps<Data>) => {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const oldItems = useRef<Data[] | undefined>(undefined);
|
||||
let { items, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(
|
||||
query(search),
|
||||
);
|
||||
if (items) oldItems.current = items;
|
||||
items ??= oldItems.current;
|
||||
|
||||
const data = useMemo(() => {
|
||||
const placeholders = [...Array(placeholderCount)].fill(null);
|
||||
if (!items) return placeholders;
|
||||
return isFetching ? [...items, ...placeholders] : items;
|
||||
}, [items, isFetching, placeholderCount]);
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
if (multiple) return new Set(values.map(getKey));
|
||||
return new Set(value !== null ? [getKey(value as Data)] : []);
|
||||
}, [value, values, multiple, getKey]);
|
||||
|
||||
return (
|
||||
<Popover.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) setSearch("");
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger aria-label={label} asChild>
|
||||
{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
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e: Event) => e.preventDefault()}
|
||||
className="z-10 flex min-w-3xs flex-col overflow-hidden rounded bg-popover shadow-xl"
|
||||
style={{
|
||||
maxHeight:
|
||||
"calc(var(--radix-popover-content-available-height) * 0.8)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center border-accent border-b px-2",
|
||||
)}
|
||||
>
|
||||
<Icon icon={SearchIcon} className="mx-1 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
// biome-ignore lint/a11y/noAutofocus: combobox search should auto-focus on open
|
||||
autoFocus
|
||||
className={cn(
|
||||
"w-full bg-transparent py-2 font-sans text-base outline-0",
|
||||
"text-slate-600 placeholder:text-slate-600/50 dark:text-slate-400 dark:placeholder:text-slate-400/50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<LegendList
|
||||
data={data}
|
||||
estimatedItemSize={40}
|
||||
keyExtractor={(item: Data | null, index: number) =>
|
||||
item ? getKey(item) : `placeholder-${index}`
|
||||
}
|
||||
renderItem={({ item }: { item: Data | null }) =>
|
||||
item ? (
|
||||
<ComboBoxItem
|
||||
label={getLabel(item)}
|
||||
selected={selectedKeys.has(getKey(item))}
|
||||
onSelect={() => {
|
||||
if (!multiple) {
|
||||
onValueChange(item);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedKeys.has(getKey(item))) {
|
||||
onValueChange([...values, item]);
|
||||
return;
|
||||
}
|
||||
onValueChange(
|
||||
values.filter((v) => getKey(v) !== getKey(item)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ComboBoxItemLoader />
|
||||
)
|
||||
}
|
||||
onEndReached={
|
||||
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
|
||||
}
|
||||
onEndReachedThreshold={0.5}
|
||||
/>
|
||||
<Popover.Arrow className="fill-popover" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ComboBoxItem = ({
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"flex w-full select-none items-center rounded py-2 pr-6 pl-8 outline-0",
|
||||
"font-sans text-slate-600 dark:text-slate-400",
|
||||
"hover:bg-accent hover:text-slate-200",
|
||||
"group",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<Icon
|
||||
icon={Check}
|
||||
className={cn(
|
||||
"absolute left-0 w-6 items-center justify-center",
|
||||
"group-hover:fill-slate-200",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-left group-hover:text-slate-200">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ComboBoxItemLoader = () => {
|
||||
return (
|
||||
<View className="flex h-10 w-full flex-row items-center py-2 pr-6 pl-8">
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,7 @@ export * from "./alert";
|
||||
export * from "./avatar";
|
||||
export * from "./button";
|
||||
export * from "./chip";
|
||||
export * from "./combobox";
|
||||
export * from "./container";
|
||||
export * from "./divider";
|
||||
export * from "./icons";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import Close from "@material-symbols/svg-400/rounded/close.svg";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import type { ReactNode } from "react";
|
||||
import { Pressable, ScrollView, View } from "react-native";
|
||||
import { cn } from "~/utils";
|
||||
@ -9,37 +9,54 @@ import { Heading } from "./text";
|
||||
export const Modal = ({
|
||||
title,
|
||||
children,
|
||||
scroll = true,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
scroll?: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
className="absolute inset-0 cursor-default! items-center justify-center bg-black/60 max-md:px-4"
|
||||
onPress={() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
presentation: "transparentModal",
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"w-full max-w-3xl rounded-md bg-background p-6",
|
||||
"max-h-[90vh] cursor-default! overflow-hidden",
|
||||
)}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
className="absolute inset-0 cursor-default! items-center justify-center bg-black/60 max-md:px-4"
|
||||
onPress={() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
}}
|
||||
>
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Heading>{title}</Heading>
|
||||
<IconButton
|
||||
icon={Close}
|
||||
onPress={() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView>{children}</ScrollView>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"w-full max-w-3xl rounded-md bg-background",
|
||||
"max-h-[90vh] cursor-default! overflow-hidden",
|
||||
)}
|
||||
onPress={(e) => e.preventDefault()}
|
||||
>
|
||||
<View className="flex-row items-center justify-between p-6">
|
||||
<Heading>{title}</Heading>
|
||||
<IconButton
|
||||
icon={Close}
|
||||
onPress={() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{scroll ? (
|
||||
<ScrollView className="p-6">{children}</ScrollView>
|
||||
) : (
|
||||
<View>{children}</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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"];
|
||||
@ -68,8 +70,8 @@ export const InfiniteFetch = <Data, Type extends string = string>({
|
||||
: placeholderCount;
|
||||
const placeholders = [...Array(count === 0 ? numColumns : count)].fill(0);
|
||||
if (!items) return placeholders;
|
||||
return isFetching ? [...items, ...placeholders] : items;
|
||||
}, [items, isFetching, placeholderCount, numColumns]);
|
||||
return isFetching && !isRefetching ? [...items, ...placeholders] : items;
|
||||
}, [items, isFetching, isRefetching, placeholderCount, numColumns]);
|
||||
|
||||
if (!data.length && Empty) return Empty;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -322,10 +322,12 @@ export const useMutation = <T = void, QueryRet = void>({
|
||||
compute,
|
||||
invalidate,
|
||||
optimistic,
|
||||
optimisticKey,
|
||||
...queryParams
|
||||
}: MutationParams & {
|
||||
compute?: (param: T) => MutationParams;
|
||||
optimistic?: (param: T, previous?: QueryRet) => QueryRet | undefined;
|
||||
optimisticKey?: QueryIdentifier<unknown>;
|
||||
invalidate: string[] | null;
|
||||
}) => {
|
||||
const { apiUrl, authToken } = useContext(AccountContext);
|
||||
@ -348,7 +350,11 @@ export const useMutation = <T = void, QueryRet = void>({
|
||||
...(invalidate && optimistic
|
||||
? {
|
||||
onMutate: async (params) => {
|
||||
const queryKey = toQueryKey({ apiUrl, path: invalidate });
|
||||
const queryKey = toQueryKey({
|
||||
apiUrl,
|
||||
path: optimisticKey?.path ?? invalidate,
|
||||
params: optimisticKey?.params,
|
||||
});
|
||||
await queryClient.cancelQueries({
|
||||
queryKey,
|
||||
});
|
||||
@ -361,7 +367,11 @@ export const useMutation = <T = void, QueryRet = void>({
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
queryClient.setQueryData(
|
||||
toQueryKey({ apiUrl, path: invalidate }),
|
||||
toQueryKey({
|
||||
apiUrl,
|
||||
path: optimisticKey?.path ?? invalidate,
|
||||
params: optimisticKey?.params,
|
||||
}),
|
||||
context!.previous,
|
||||
);
|
||||
},
|
||||
|
||||
1
front/src/ui/admin/index.tsx
Normal file
1
front/src/ui/admin/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from "./videos-modal";
|
||||
92
front/src/ui/admin/videos-modal/headers.tsx
Normal file
92
front/src/ui/admin/videos-modal/headers.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import Path from "@material-symbols/svg-400/rounded/conversion_path-fill.svg";
|
||||
import LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg";
|
||||
import Sort from "@material-symbols/svg-400/rounded/sort.svg";
|
||||
import Entry from "@material-symbols/svg-400/rounded/tv_next-fill.svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullVideo } from "~/models";
|
||||
import {
|
||||
Button,
|
||||
ComboBox,
|
||||
Icon,
|
||||
Menu,
|
||||
P,
|
||||
PressableFeedback,
|
||||
tooltip,
|
||||
} from "~/primitives";
|
||||
|
||||
const sortModes = [
|
||||
["path", Path],
|
||||
["entry", Entry],
|
||||
] as const;
|
||||
|
||||
export const SortMenu = ({
|
||||
sort,
|
||||
setSort,
|
||||
}: {
|
||||
sort: "path" | "entry";
|
||||
setSort: (sort: "path" | "entry") => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Menu
|
||||
Trigger={(props) => (
|
||||
<PressableFeedback
|
||||
className="flex-row items-center"
|
||||
{...tooltip(t("browse.sortby-tt"))}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon={Sort} className="mx-1" />
|
||||
<P>{t(`videos-map.sort-${sort}`)}</P>
|
||||
</PressableFeedback>
|
||||
)}
|
||||
>
|
||||
{sortModes.map((x) => (
|
||||
<Menu.Item
|
||||
key={x[0]}
|
||||
icon={x[1]}
|
||||
label={t(`videos-map.sort-${x[0]}`)}
|
||||
selected={sort === x[0]}
|
||||
onSelect={() => setSort(x[0])}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddVideoFooter = ({
|
||||
addTitle,
|
||||
}: {
|
||||
addTitle: (title: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ComboBox
|
||||
Trigger={(props) => (
|
||||
<Button
|
||||
icon={LibraryAdd}
|
||||
text={t("videos-map.add")}
|
||||
className="m-6 mt-10"
|
||||
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={(x) => {
|
||||
if (x) addTitle(x.guess.title);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
130
front/src/ui/admin/videos-modal/index.tsx
Normal file
130
front/src/ui/admin/videos-modal/index.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { type Entry, FullVideo, type Page } from "~/models";
|
||||
import { IconButton, Modal, P, Skeleton, tooltip } from "~/primitives";
|
||||
import {
|
||||
InfiniteFetch,
|
||||
type QueryIdentifier,
|
||||
useFetch,
|
||||
useMutation,
|
||||
} from "~/query";
|
||||
import { useQueryState } from "~/utils";
|
||||
import { Header } from "../../details/header";
|
||||
import { AddVideoFooter, SortMenu } from "./headers";
|
||||
import { PathItem } from "./path-item";
|
||||
|
||||
export const useEditLinks = (
|
||||
slug: string,
|
||||
titles: string[],
|
||||
sort: "path" | "entry",
|
||||
) => {
|
||||
const { mutateAsync } = useMutation({
|
||||
method: "PUT",
|
||||
path: ["api", "videos", "link"],
|
||||
compute: ({
|
||||
video,
|
||||
entries,
|
||||
guess = false,
|
||||
}: {
|
||||
video: string;
|
||||
entries: Omit<Entry, "href" | "progress" | "videos">[];
|
||||
guess?: boolean;
|
||||
}) => ({
|
||||
body: [
|
||||
{
|
||||
id: video,
|
||||
for: entries.map((x) =>
|
||||
guess && x.kind === "episode"
|
||||
? {
|
||||
serie: slug,
|
||||
// @ts-expect-error: idk why it couldn't match x as an episode
|
||||
season: x.seasonNumber,
|
||||
// @ts-expect-error: idk why it couldn't match x as an episode
|
||||
episode: x.episodeNumber,
|
||||
}
|
||||
: { slug: x.slug },
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
invalidate: ["api", "series", slug],
|
||||
optimisticKey: VideosModal.query(slug, titles, sort),
|
||||
optimistic: (params, prev?: { pages: Page<FullVideo>[] }) => ({
|
||||
...prev!,
|
||||
pages: prev!.pages.map((p) => ({
|
||||
...p,
|
||||
items: p!.items.map((x) => {
|
||||
if (x.id !== params.video) return x;
|
||||
return { ...x, entries: params.entries };
|
||||
}) as FullVideo[],
|
||||
})),
|
||||
}),
|
||||
});
|
||||
return mutateAsync;
|
||||
};
|
||||
|
||||
export const VideosModal = () => {
|
||||
const [slug] = useQueryState<string>("slug", undefined!);
|
||||
const { data } = useFetch(Header.query("serie", slug));
|
||||
const { t } = useTranslation();
|
||||
const [titles, setTitles] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<"entry" | "path">("path");
|
||||
const editLinks = useEditLinks(slug, titles, sort);
|
||||
|
||||
const addTitle = (x: string) => {
|
||||
if (!titles.includes(x)) setTitles([...titles, x]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={data?.name ?? t("misc.loading")} scroll={false}>
|
||||
{[...titles].map((title) => (
|
||||
<View
|
||||
key={title}
|
||||
className="m-2 flex-row items-center justify-between rounded bg-card px-6"
|
||||
>
|
||||
<P>{t("videos-map.related", { title })}</P>
|
||||
<IconButton
|
||||
icon={Close}
|
||||
onPress={() => {
|
||||
setTitles(titles.filter((x) => x !== title));
|
||||
}}
|
||||
{...tooltip(t("misc.cancel"))}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
<View className="mx-6 mb-6 flex-row items-center">
|
||||
<SortMenu sort={sort} setSort={setSort} />
|
||||
</View>
|
||||
<InfiniteFetch
|
||||
query={VideosModal.query(slug, titles, sort)}
|
||||
layout={{ layout: "vertical", gap: 8, numColumns: 1, size: 48 }}
|
||||
Render={({ item }) => (
|
||||
<PathItem
|
||||
item={item}
|
||||
serieSlug={slug}
|
||||
addTitle={addTitle}
|
||||
editLinks={editLinks}
|
||||
/>
|
||||
)}
|
||||
Loader={() => <Skeleton />}
|
||||
Footer={<AddVideoFooter addTitle={addTitle} />}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
VideosModal.query = (
|
||||
slug: string,
|
||||
titles: string[],
|
||||
sort: "path" | "entry",
|
||||
): QueryIdentifier<FullVideo> => ({
|
||||
parser: FullVideo,
|
||||
path: ["api", "series", slug, "videos"],
|
||||
params: {
|
||||
sort: sort === "entry" ? ["entry", "path"] : sort,
|
||||
titles: titles,
|
||||
},
|
||||
infinite: true,
|
||||
});
|
||||
1
front/src/ui/admin/videos-modal/mutate.ts
Normal file
1
front/src/ui/admin/videos-modal/mutate.ts
Normal file
@ -0,0 +1 @@
|
||||
export {};
|
||||
109
front/src/ui/admin/videos-modal/path-item.tsx
Normal file
109
front/src/ui/admin/videos-modal/path-item.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
|
||||
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
|
||||
import Question from "@material-symbols/svg-400/rounded/question_mark-fill.svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { entryDisplayNumber } from "~/components/entries";
|
||||
import { Entry, type FullVideo } from "~/models";
|
||||
import { ComboBox, IconButton, P, tooltip } from "~/primitives";
|
||||
import { uniqBy } from "~/utils";
|
||||
import type { useEditLinks } from ".";
|
||||
|
||||
export const PathItem = ({
|
||||
item,
|
||||
serieSlug,
|
||||
addTitle,
|
||||
editLinks,
|
||||
}: {
|
||||
item: FullVideo;
|
||||
serieSlug: string;
|
||||
addTitle: (title: string) => void;
|
||||
editLinks: ReturnType<typeof useEditLinks>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const saved = item.entries.length;
|
||||
const guess = !saved
|
||||
? uniqBy(
|
||||
item.guess.episodes.map(
|
||||
(x) =>
|
||||
({
|
||||
kind: "episode",
|
||||
id: `s${x.season}-e${x.episode}`,
|
||||
seasonNumber: x.season,
|
||||
episodeNumber: x.episode,
|
||||
}) as Entry,
|
||||
),
|
||||
(x) => x.id,
|
||||
)
|
||||
: [];
|
||||
return (
|
||||
<View
|
||||
className="mx-6 h-12 flex-row items-center justify-between hover:bg-card"
|
||||
style={!saved && { opacity: 0.6 }}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
{saved ? (
|
||||
<IconButton
|
||||
icon={Close}
|
||||
onPress={async () => {
|
||||
addTitle(item.guess.title);
|
||||
await editLinks({ video: item.id, entries: [] });
|
||||
}}
|
||||
{...tooltip(t("videos-map.delete"))}
|
||||
/>
|
||||
) : guess.length ? (
|
||||
<IconButton
|
||||
icon={Check}
|
||||
onPress={async () => {
|
||||
await editLinks({
|
||||
video: item.id,
|
||||
entries: guess,
|
||||
guess: true,
|
||||
});
|
||||
}}
|
||||
{...tooltip(t("videos-map.validate"))}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
disabled
|
||||
icon={Question}
|
||||
{...tooltip(t("videos-map.no-guess"))}
|
||||
/>
|
||||
)}
|
||||
<P>{item.path}</P>
|
||||
</View>
|
||||
<ComboBox
|
||||
multiple
|
||||
label={t("videos-map.none")}
|
||||
searchPlaceholder={t("navbar.search")}
|
||||
values={saved ? item.entries : guess}
|
||||
query={(q) => ({
|
||||
parser: Entry,
|
||||
path: ["api", "series", serieSlug, "entries"],
|
||||
params: {
|
||||
query: q,
|
||||
},
|
||||
infinite: true,
|
||||
})}
|
||||
getKey={
|
||||
saved
|
||||
? (x) => x.id
|
||||
: (x) =>
|
||||
x.kind === "episode"
|
||||
? `${x.seasonNumber}-${x.episodeNumber}`
|
||||
: x.id
|
||||
}
|
||||
getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
|
||||
getSmallLabel={entryDisplayNumber}
|
||||
onValueChange={async (entries) => {
|
||||
if (!entries.length) addTitle(item.guess.title);
|
||||
await editLinks({
|
||||
video: item.id,
|
||||
entries,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,7 @@ import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
|
||||
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
|
||||
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
||||
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
|
||||
import VideoLibrary from "@material-symbols/svg-400/rounded/video_library-fill.svg";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
@ -116,16 +117,21 @@ const ButtonList = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* {account?.isAdmin === true && ( */}
|
||||
{/* <> */}
|
||||
{/* {kind === "movie" && <HR />} */}
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.refreshMetadata")} */}
|
||||
{/* icon={Refresh} */}
|
||||
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
|
||||
{/* /> */}
|
||||
{/* </> */}
|
||||
{/* )} */}
|
||||
{account?.isAdmin === true && (
|
||||
<>
|
||||
{kind === "movie" && <HR />}
|
||||
<Menu.Item
|
||||
label={t("show.videos-map")}
|
||||
icon={VideoLibrary}
|
||||
href={`/series/${slug}/videos`}
|
||||
/>
|
||||
{/* <Menu.Item */}
|
||||
{/* label={t("home.refreshMetadata")} */}
|
||||
{/* icon={Refresh} */}
|
||||
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
|
||||
{/* /> */}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@ -149,7 +149,6 @@ export const EntryList = ({
|
||||
{index === 0 ? <SeasonHeader.Loader /> : <EntryLine.Loader />}
|
||||
</Container>
|
||||
)}
|
||||
margin={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -75,3 +75,13 @@ export function shuffle<T>(array: T[]): T[] {
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
export function uniqBy<T>(a: T[], key: (val: T) => string | number): T[] {
|
||||
const seen: Record<string, boolean> = {};
|
||||
return a.filter((item) => {
|
||||
const k = key(item);
|
||||
if (seen[k]) return false;
|
||||
seen[k] = true;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ class KyooClient(metaclass=Singleton):
|
||||
)
|
||||
|
||||
async def get_videos_info(self) -> VideoInfo:
|
||||
async with self._client.get("videos") as r:
|
||||
async with self._client.get("videos/guesses") as r:
|
||||
await self.raise_for_status(r)
|
||||
return VideoInfo(**await r.json())
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user