From 0c4e3896d7ab474c189ab12a8e2707549bdef75e Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Sun, 22 Feb 2026 20:35:14 +0100
Subject: [PATCH 01/12] Split video controller in 3
---
api/src/base.ts | 7 +-
api/src/controllers/seed/videos.ts | 419 ++++++++++++++++++
api/src/controllers/video-metadata.ts | 188 +++++++++
api/src/controllers/videos.ts | 587 +-------------------------
api/tests/helpers/videos-helper.ts | 4 +-
api/tests/manual.ts | 4 +-
api/tests/videos/getdel.test.ts | 8 +-
scanner/scanner/client.py | 2 +-
8 files changed, 624 insertions(+), 595 deletions(-)
create mode 100644 api/src/controllers/seed/videos.ts
create mode 100644 api/src/controllers/video-metadata.ts
diff --git a/api/src/base.ts b/api/src/base.ts
index eb713601..b6cf5e8b 100644
--- a/api/src/base.ts
+++ b/api/src/base.ts
@@ -8,13 +8,15 @@ import { nextup } from "./controllers/profiles/nextup";
import { watchlistH } from "./controllers/profiles/watchlist";
import { seasonsH } from "./controllers/seasons";
import { seed } from "./controllers/seed";
+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 +126,8 @@ export const handlers = new Elysia({ prefix })
.use(watchlistH)
.use(historyH)
.use(nextup)
- .use(videosReadH),
+ .use(videosReadH)
+ .use(videosMetadata),
)
.guard(
{
diff --git a/api/src/controllers/seed/videos.ts b/api/src/controllers/seed/videos.ts
new file mode 100644
index 00000000..97473ae1
--- /dev/null
+++ b/api/src/controllers/seed/videos.ts
@@ -0,0 +1,419 @@
+import { and, eq, gt, ne, notExists, 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 {
+ conflictUpdateAllExcept,
+ isUniqueConstraint,
+ sqlarr,
+ unnest,
+ 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 { computeVideoSlug } from "./insert/entries";
+import { updateAvailableCount, updateAvailableSince } from "./insert/shows";
+
+async function linkVideos(
+ tx: Transaction,
+ links: {
+ video: number;
+ entry: Omit & {
+ 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,
+ );
+
+ 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;
+}
+
+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"] }),
+ }),
+ ),
+});
+
+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 }) => {
+ 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] ?? [],
+ })),
+ );
+ });
+ },
+ {
+ detail: {
+ description: comment`
+ 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.
+ `,
+ },
+ 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()) },
+ },
+ )
+ .post(
+ "/link",
+ async ({ body, status }) => {
+ return await db.transaction(async (tx) => {
+ 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 lVids = body.flatMap((x) => {
+ return 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,
+ },
+ }));
+ });
+ const links = await linkVideos(tx, lVids);
+ 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: t.Array(
+ t.Object({
+ id: t.String({
+ description: "Id of the video",
+ format: "uuid",
+ }),
+ for: t.Array(SeedVideo.properties.for.items),
+ }),
+ ),
+ response: {
+ 201: 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"],
+ }),
+ }),
+ ),
+ }),
+ ),
+ 422: KError,
+ },
+ },
+ );
diff --git a/api/src/controllers/video-metadata.ts b/api/src/controllers/video-metadata.ts
new file mode 100644
index 00000000..06c2df15
--- /dev/null
+++ b/api/src/controllers/video-metadata.ts
@@ -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.",
+ },
+ },
+ },
+ );
diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts
index 04bdeb0b..5a38b126 100644
--- a/api/src/controllers/videos.ts
+++ b/api/src/controllers/videos.ts
@@ -7,7 +7,6 @@ import {
lt,
max,
min,
- ne,
notExists,
or,
sql,
@@ -15,7 +14,7 @@ import {
import { alias } from "drizzle-orm/pg-core";
import { Elysia, t } from "elysia";
import { auth } from "~/auth";
-import { db, type Transaction } from "~/db";
+import { db } from "~/db";
import {
entries,
entryVideoJoin,
@@ -28,19 +27,14 @@ import {
import { watchlist } from "~/db/schema/watchlist";
import {
coalesce,
- conflictUpdateAllExcept,
getColumns,
- isUniqueConstraint,
jsonbAgg,
jsonbBuildObject,
jsonbObjectAgg,
sqlarr,
- unnest,
- unnestValues,
} from "~/db/utils";
import { Entry } from "~/models/entry";
import { KError } from "~/models/error";
-import { bubbleVideo } from "~/models/examples";
import { Progress } from "~/models/history";
import { Movie, type MovieStatus } from "~/models/movie";
import { Serie } from "~/models/serie";
@@ -58,178 +52,14 @@ import {
sortToSql,
} from "~/models/utils";
import { desc as description } from "~/models/utils/descriptions";
-import { Guess, Guesses, SeedVideo, Video } from "~/models/video";
+import { Guesses, Video } from "~/models/video";
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
-import { comment } from "~/utils";
import {
entryProgressQ,
entryVideosQ,
getEntryTransQ,
mapProgress,
} from "./entries";
-import { computeVideoSlug } from "./seed/insert/entries";
-import {
- updateAvailableCount,
- updateAvailableSince,
-} from "./seed/insert/shows";
-
-async function linkVideos(
- tx: Transaction,
- links: {
- video: number;
- entry: Omit & {
- 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,
- );
-
- 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;
-}
-
-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"] }),
- }),
- ),
-});
const videoRelations = {
slugs: () => {
@@ -564,177 +394,7 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
},
)
.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.",
- },
- },
- },
- )
- .get(
- "",
+ "guesses",
async () => {
const years = db.$with("years").as(
db
@@ -853,244 +513,3 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
},
},
);
-
-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 }) => {
- 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] ?? [],
- })),
- );
- });
- },
- {
- detail: {
- description: comment`
- 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.
- `,
- },
- 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()) },
- },
- )
- .post(
- "/link",
- async ({ body, status }) => {
- return await db.transaction(async (tx) => {
- 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 lVids = body.flatMap((x) => {
- return 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,
- },
- }));
- });
- const links = await linkVideos(tx, lVids);
- 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: t.Array(
- t.Object({
- id: t.String({
- description: "Id of the video",
- format: "uuid",
- }),
- for: t.Array(SeedVideo.properties.for.items),
- }),
- ),
- response: {
- 201: 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"],
- }),
- }),
- ),
- }),
- ),
- 422: KError,
- },
- },
- );
diff --git a/api/tests/helpers/videos-helper.ts b/api/tests/helpers/videos-helper.ts
index c301d3a4..87803fdd 100644
--- a/api/tests/helpers/videos-helper.ts
+++ b/api/tests/helpers/videos-helper.ts
@@ -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(),
}),
diff --git a/api/tests/manual.ts b/api/tests/manual.ts
index 0bdce7ee..f4c6d849 100644
--- a/api/tests/manual.ts
+++ b/api/tests/manual.ts
@@ -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);
diff --git a/api/tests/videos/getdel.test.ts b/api/tests/videos/getdel.test.ts
index 0cd78443..81ec8119 100644
--- a/api/tests/videos/getdel.test.ts
+++ b/api/tests/videos/getdel.test.ts
@@ -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: {
diff --git a/scanner/scanner/client.py b/scanner/scanner/client.py
index aaf05aaf..3738957f 100644
--- a/scanner/scanner/client.py
+++ b/scanner/scanner/client.py
@@ -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())
From 9e1ddcc9b85a3fa54b747f35a018e346efc268b7 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Sun, 22 Feb 2026 22:55:40 +0100
Subject: [PATCH 02/12] Split video's entries query for performances
---
api/src/controllers/videos.ts | 309 ++++++++++++++++++++++------------
api/src/db/schema/videos.ts | 6 +-
api/src/models/full-video.ts | 43 +++++
api/src/models/utils/sort.ts | 57 ++++---
api/src/utils.ts | 2 +-
api/tsconfig.json | 4 +-
6 files changed, 282 insertions(+), 139 deletions(-)
create mode 100644 api/src/models/full-video.ts
diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts
index 5a38b126..57f75dcd 100644
--- a/api/src/controllers/videos.ts
+++ b/api/src/controllers/videos.ts
@@ -9,6 +9,7 @@ import {
min,
notExists,
or,
+ type SQL,
sql,
} from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
@@ -26,18 +27,17 @@ import {
} from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import {
- coalesce,
getColumns,
- jsonbAgg,
jsonbBuildObject,
jsonbObjectAgg,
sqlarr,
} from "~/db/utils";
-import { Entry } from "~/models/entry";
+import type { Entry } from "~/models/entry";
import { KError } from "~/models/error";
-import { Progress } from "~/models/history";
-import { Movie, type MovieStatus } from "~/models/movie";
-import { Serie } from "~/models/serie";
+import { FullVideo } from "~/models/full-video";
+import type { Progress } from "~/models/history";
+import type { Movie, MovieStatus } from "~/models/movie";
+import type { Serie } from "~/models/serie";
import {
AcceptLanguage,
buildRelations,
@@ -54,6 +54,7 @@ import {
import { desc as description } from "~/models/utils/descriptions";
import { Guesses, Video } from "~/models/video";
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
+import { uniqBy } from "~/utils";
import {
entryProgressQ,
entryVideosQ,
@@ -61,19 +62,29 @@ import {
mapProgress,
} from "./entries";
-const videoRelations = {
- slugs: () => {
- return db
- .select({
- slugs: coalesce(
- jsonbAgg(entryVideoJoin.slug),
- sql`'[]'::jsonb`,
- ).as("slugs"),
- })
- .from(entryVideoJoin)
- .where(eq(entryVideoJoin.videoPk, videos.pk))
- .as("slugs");
+const videoSort = Sort(
+ {
+ path: videos.path,
+ entry: [
+ {
+ sql: entries.showPk,
+ isNullable: true,
+ accessor: (x: any) => x.entries?.[0]?.showPk,
+ },
+ {
+ sql: entries.order,
+ isNullable: true,
+ accessor: (x: any) => x.entries?.[0]?.order,
+ },
+ ],
},
+ {
+ default: ["path"],
+ tablePk: videos.pk,
+ },
+);
+
+const videoRelations = {
progress: () => {
const query = db
.select({
@@ -103,32 +114,6 @@ const videoRelations = {
as "progress"
)` as any;
},
- entries: ({ languages }: { languages: string[] }) => {
- const transQ = getEntryTransQ(languages);
-
- return db
- .select({
- json: coalesce(
- jsonbAgg(
- jsonbBuildObject({
- ...getColumns(entries),
- ...getColumns(transQ),
- number: entries.episodeNumber,
- videos: entryVideosQ.videos,
- progress: mapProgress({ aliased: false }),
- }),
- ),
- sql`'[]'::jsonb`,
- ).as("json"),
- })
- .from(entries)
- .innerJoin(transQ, eq(entries.pk, transQ.pk))
- .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk))
- .crossJoinLateral(entryVideosQ)
- .innerJoin(entryVideoJoin, eq(entryVideoJoin.entryPk, entries.pk))
- .where(eq(entryVideoJoin.videoPk, videos.pk))
- .as("entries");
- },
show: ({
languages,
preferOriginal,
@@ -277,11 +262,102 @@ function getNextVideoEntry({
.as("next");
}
+// make an alias so entry video join is not usable on subqueries
+const evJoin = alias(entryVideoJoin, "evj");
+
+export async function getVideos({
+ after,
+ limit,
+ query,
+ sort,
+ filter,
+ languages,
+ preferOriginal = false,
+ relations = [],
+ userId,
+}: {
+ after?: string;
+ limit: number;
+ query?: string;
+ sort?: Sort;
+ filter?: SQL;
+ languages: string[];
+ preferOriginal?: boolean;
+ relations?: (keyof typeof videoRelations)[];
+ userId: string;
+}) {
+ let ret = await db
+ .select({
+ ...getColumns(videos),
+ ...buildRelations(relations, videoRelations, {
+ languages,
+ preferOriginal,
+ }),
+ })
+ .from(videos)
+ .leftJoin(evJoin, eq(videos.pk, evJoin.videoPk))
+ // join entries only for sorting, we can't select entries here for perf reasons.
+ .leftJoin(entries, eq(entries.pk, evJoin.entryPk))
+ .where(
+ and(
+ filter,
+ query ? sql`${videos.path} %> ${query}::text` : undefined,
+ keysetPaginate({ after, sort }),
+ ),
+ )
+ .orderBy(
+ ...(query
+ ? [sql`word_similarity(${query}::text, ${videos.path}) desc`]
+ : sortToSql(sort)),
+ videos.pk,
+ )
+ .limit(limit)
+ .execute({ userId });
+
+ ret = uniqBy(ret, (x) => x.pk);
+ if (!ret.length) return [];
+
+ const entriesByVideo = await fetchEntriesForVideos({
+ videoPks: ret.map((x) => x.pk),
+ languages,
+ userId,
+ });
+
+ return ret.map((x) => ({
+ ...x,
+ entries: entriesByVideo[x.pk] ?? [],
+ })) as unknown as FullVideo[];
+}
+
+async function fetchEntriesForVideos({
+ videoPks,
+ languages,
+ userId,
+}: {
+ videoPks: number[];
+ languages: string[];
+ userId: string;
+}) {
+ if (!videoPks.length) return {};
+
+ const transQ = getEntryTransQ(languages);
+ const ret = await db
+ .select({
+ videoPk: entryVideoJoin.videoPk,
+ ...getColumns(entries),
+ ...getColumns(transQ),
+ number: entries.episodeNumber,
+ })
+ .from(entryVideoJoin)
+ .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk))
+ .innerJoin(transQ, eq(entries.pk, transQ.pk))
+ .where(eq(entryVideoJoin.videoPk, sql`any(${sqlarr(videoPks)})`))
+ .execute({ userId });
+
+ return Object.groupBy(ret, (x) => x.videoPk);
+}
+
export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
- .model({
- video: Video,
- error: t.Object({}),
- })
.use(auth)
.get(
":id",
@@ -293,34 +369,21 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
status,
}) => {
const languages = processLanguages(langs);
-
- // make an alias so entry video join is not usable on subqueries
- const evj = alias(entryVideoJoin, "evj");
-
- const [video] = await db
- .select({
- ...getColumns(videos),
- ...buildRelations(
- ["slugs", "progress", "entries", ...relations],
- videoRelations,
- {
- languages,
- preferOriginal: preferOriginal ?? settings.preferOriginal,
- },
- ),
- })
- .from(videos)
- .leftJoin(evj, eq(videos.pk, evj.videoPk))
- .where(isUuid(id) ? eq(videos.id, id) : eq(evj.slug, id))
- .limit(1)
- .execute({ userId: sub });
- if (!video) {
+ const [ret] = await getVideos({
+ limit: 1,
+ filter: and(isUuid(id) ? eq(videos.id, id) : eq(evJoin.slug, id)),
+ languages,
+ preferOriginal: preferOriginal ?? settings.preferOriginal,
+ relations,
+ userId: sub,
+ });
+ if (!ret) {
return status(404, {
status: 404,
message: `No video found with id or slug '${id}'`,
});
}
- return video as any;
+ return ret;
},
{
detail: {
@@ -347,44 +410,65 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
"accept-language": AcceptLanguage(),
}),
response: {
- 200: t.Composite([
- Video,
- t.Object({
- slugs: t.Array(
- t.String({ format: "slug", examples: ["made-in-abyss-s1e13"] }),
- ),
- progress: Progress,
- entries: t.Array(Entry),
- 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]),
- ]),
- ),
+ 200: FullVideo,
+ 404: {
+ ...KError,
+ description: "No video found with the given id or slug.",
+ },
+ 422: KError,
+ },
+ },
+ )
+ .get(
+ "",
+ async ({
+ query: { limit, after, query, sort, with: relations, preferOriginal },
+ headers: { "accept-language": langs, ...headers },
+ request: { url },
+ jwt: { sub, settings },
+ }) => {
+ const languages = processLanguages(langs);
+ const items = await getVideos({
+ limit,
+ after,
+ query,
+ sort,
+ languages,
+ preferOriginal: preferOriginal ?? settings.preferOriginal,
+ relations,
+ userId: sub,
+ });
+ return createPage(items, { url, sort, limit, headers });
+ },
+ {
+ detail: {
+ description: "Get a video & it's related entries",
+ },
+ query: t.Object({
+ sort: videoSort,
+ query: t.Optional(t.String({ description: description.query })),
+ limit: t.Integer({
+ minimum: 1,
+ maximum: 250,
+ default: 50,
+ description: "Max page size.",
+ }),
+ after: t.Optional(t.String({ description: description.after })),
+ preferOriginal: t.Optional(
+ t.Boolean({
+ description: description.preferOriginal,
}),
- ]),
+ ),
+ with: t.Array(t.UnionEnum(["previous", "next", "show"]), {
+ default: [],
+ description: "Include related entries in the response.",
+ }),
+ }),
+ headers: t.Object({
+ "accept-language": AcceptLanguage(),
+ }),
+ response: {
+ 200: Page(FullVideo),
404: {
...KError,
description: "No video found with the given id or slug.",
@@ -512,4 +596,13 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
422: KError,
},
},
+ )
+ .get(
+ "/series/:id/videos",
+ async () => {
+ return {};
+ },
+ {
+ detail: { description: "List videos of a serie" },
+ },
);
diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts
index 7e3cc836..eab72392 100644
--- a/api/src/db/schema/videos.ts
+++ b/api/src/db/schema/videos.ts
@@ -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 }) => ({
diff --git a/api/src/models/full-video.ts b/api/src/models/full-video.ts
new file mode 100644
index 00000000..cef94374
--- /dev/null
+++ b/api/src/models/full-video.ts
@@ -0,0 +1,43 @@
+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({
+ 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;
diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts
index f0f2b5da..2decc59a 100644
--- a/api/src/models/utils/sort.ts
+++ b/api/src/models/utils/sort.ts
@@ -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,
{
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]);
}),
};
})
diff --git a/api/src/utils.ts b/api/src/utils.ts
index 207acc28..8abceea8 100644
--- a/api/src/utils.ts
+++ b/api/src/utils.ts
@@ -33,7 +33,7 @@ export function uniq(a: T[]): T[] {
return uniqBy(a, (x) => x as string);
}
-export function uniqBy(a: T[], key: (val: T) => string): T[] {
+export function uniqBy(a: T[], key: (val: T) => string | number): T[] {
const seen: Record = {};
return a.filter((item) => {
const k = key(item);
diff --git a/api/tsconfig.json b/api/tsconfig.json
index 67912b95..05ef7fa6 100644
--- a/api/tsconfig.json
+++ b/api/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
- "target": "ES2022",
- "module": "ES2022",
+ "target": "esnext",
+ "module": "esnext",
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
From 61d29d183f743d9dfac5b8cfec82aa6d68e14e49 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Mon, 23 Feb 2026 00:19:12 +0100
Subject: [PATCH 03/12] Add `/series/:id/videos` route
---
api/src/controllers/videos.ts | 94 +++++++++++++++++++++++++++++------
1 file changed, 79 insertions(+), 15 deletions(-)
diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts
index 57f75dcd..983e91c7 100644
--- a/api/src/controllers/videos.ts
+++ b/api/src/controllers/videos.ts
@@ -79,7 +79,7 @@ const videoSort = Sort(
],
},
{
- default: ["path"],
+ default: ["entry"],
tablePk: videos.pk,
},
);
@@ -357,10 +357,10 @@ async function fetchEntriesForVideos({
return Object.groupBy(ret, (x) => x.videoPk);
}
-export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
+export const videosReadH = new Elysia({ tags: ["videos"] })
.use(auth)
.get(
- ":id",
+ "videos/:id",
async ({
params: { id },
query: { with: relations, preferOriginal },
@@ -420,9 +420,9 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
},
)
.get(
- "",
+ "videos",
async ({
- query: { limit, after, query, sort, with: relations, preferOriginal },
+ query: { limit, after, query, sort, preferOriginal },
headers: { "accept-language": langs, ...headers },
request: { url },
jwt: { sub, settings },
@@ -435,7 +435,6 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
sort,
languages,
preferOriginal: preferOriginal ?? settings.preferOriginal,
- relations,
userId: sub,
});
return createPage(items, { url, sort, limit, headers });
@@ -459,10 +458,6 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
description: description.preferOriginal,
}),
),
- with: t.Array(t.UnionEnum(["previous", "next", "show"]), {
- default: [],
- description: "Include related entries in the response.",
- }),
}),
headers: t.Object({
"accept-language": AcceptLanguage(),
@@ -478,7 +473,7 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
},
)
.get(
- "guesses",
+ "videos/guesses",
async () => {
const years = db.$with("years").as(
db
@@ -549,7 +544,7 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
},
)
.get(
- "unmatched",
+ "videos/unmatched",
async ({ query: { sort, query, limit, after }, request: { url } }) => {
const ret = await db
.select()
@@ -598,11 +593,80 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
},
)
.get(
- "/series/:id/videos",
- async () => {
- return {};
+ "series/:id/videos",
+ async ({
+ params: { id },
+ query: { limit, after, query, sort, preferOriginal },
+ headers: { "accept-language": langs, ...headers },
+ request: { url },
+ jwt: { sub, settings },
+ status,
+ }) => {
+ const [serie] = await db
+ .select({ pk: shows.pk })
+ .from(shows)
+ .where(
+ and(
+ eq(shows.kind, "serie"),
+ isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
+ ),
+ )
+ .limit(1);
+
+ if (!serie) {
+ return status(404, {
+ status: 404,
+ message: `No serie with the id or slug: '${id}'.`,
+ });
+ }
+
+ const languages = processLanguages(langs);
+ const items = await getVideos({
+ filter: eq(entries.showPk, serie.pk),
+ limit,
+ after,
+ query,
+ sort,
+ languages,
+ preferOriginal: preferOriginal ?? settings.preferOriginal,
+ userId: sub,
+ });
+ return createPage(items, { url, sort, limit, headers });
},
{
detail: { description: "List videos of a serie" },
+ params: t.Object({
+ id: t.String({
+ description: "The id or slug of the serie.",
+ example: "made-in-abyss",
+ }),
+ }),
+ query: t.Object({
+ sort: videoSort,
+ query: t.Optional(t.String({ description: description.query })),
+ limit: t.Integer({
+ minimum: 1,
+ maximum: 250,
+ default: 50,
+ description: "Max page size.",
+ }),
+ after: t.Optional(t.String({ description: description.after })),
+ preferOriginal: t.Optional(
+ t.Boolean({
+ description: description.preferOriginal,
+ }),
+ ),
+ }),
+ headers: t.Object({
+ "accept-language": AcceptLanguage(),
+ }),
+ response: {
+ 200: Page(FullVideo),
+ 404: {
+ ...KError,
+ description: "No video found with the given id or slug.",
+ },
+ 422: KError,
+ },
},
);
From b55a61b944946621ccb2609499b8eb87f815dd1f Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Mon, 23 Feb 2026 17:03:34 +0100
Subject: [PATCH 04/12] Add video mapper component
---
front/src/app/(app)/_layout.tsx | 13 +---
.../series/{[slug].tsx => [slug]/index.tsx} | 0
front/src/app/(app)/series/[slug]/videos.tsx | 3 +
front/src/components/entries/index.ts | 2 +-
front/src/models/video.ts | 25 +++++---
front/src/primitives/modal.tsx | 59 +++++++++++--------
front/src/ui/admin/index.tsx | 1 +
front/src/ui/admin/videos-modal.tsx | 40 +++++++++++++
8 files changed, 98 insertions(+), 45 deletions(-)
rename front/src/app/(app)/series/{[slug].tsx => [slug]/index.tsx} (100%)
create mode 100644 front/src/app/(app)/series/[slug]/videos.tsx
create mode 100644 front/src/ui/admin/index.tsx
create mode 100644 front/src/ui/admin/videos-modal.tsx
diff --git a/front/src/app/(app)/_layout.tsx b/front/src/app/(app)/_layout.tsx
index eeafe777..e64491bd 100644
--- a/front/src/app/(app)/_layout.tsx
+++ b/front/src/app/(app)/_layout.tsx
@@ -28,17 +28,6 @@ export default function Layout() {
},
headerTintColor: color as string,
}}
- >
-
-
+ />
);
}
diff --git a/front/src/app/(app)/series/[slug].tsx b/front/src/app/(app)/series/[slug]/index.tsx
similarity index 100%
rename from front/src/app/(app)/series/[slug].tsx
rename to front/src/app/(app)/series/[slug]/index.tsx
diff --git a/front/src/app/(app)/series/[slug]/videos.tsx b/front/src/app/(app)/series/[slug]/videos.tsx
new file mode 100644
index 00000000..757712ea
--- /dev/null
+++ b/front/src/app/(app)/series/[slug]/videos.tsx
@@ -0,0 +1,3 @@
+import { VideosModal } from "~/ui/admin";
+
+export default VideosModal;
diff --git a/front/src/components/entries/index.ts b/front/src/components/entries/index.ts
index aecb4df4..f472eb6b 100644
--- a/front/src/components/entries/index.ts
+++ b/front/src/components/entries/index.ts
@@ -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) => {
switch (entry.kind) {
case "episode":
return `S${entry.seasonNumber}:E${entry.episodeNumber}`;
diff --git a/front/src/models/video.ts b/front/src/models/video.ts
index 50b81263..80fa5f3f 100644
--- a/front/src/models/video.ts
+++ b/front/src/models/video.ts
@@ -1,5 +1,5 @@
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";
@@ -37,14 +37,21 @@ export const Video = z.object({
});
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(),
diff --git a/front/src/primitives/modal.tsx b/front/src/primitives/modal.tsx
index f7a3d912..0d8f7171 100644
--- a/front/src/primitives/modal.tsx
+++ b/front/src/primitives/modal.tsx
@@ -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,50 @@ import { Heading } from "./text";
export const Modal = ({
title,
children,
+ scroll = true,
}: {
title: string;
children: ReactNode;
+ scroll?: boolean;
}) => {
const router = useRouter();
return (
- {
- if (router.canGoBack()) router.back();
- }}
- >
+ <>
+
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();
+ }}
>
-
- {title}
- {
- if (router.canGoBack()) router.back();
- }}
- />
-
- {children}
+ e.preventDefault()}
+ >
+
+ {title}
+ {
+ if (router.canGoBack()) router.back();
+ }}
+ />
+
+ {scroll ? {children} : children}
+
-
+ >
);
};
diff --git a/front/src/ui/admin/index.tsx b/front/src/ui/admin/index.tsx
new file mode 100644
index 00000000..d75d2289
--- /dev/null
+++ b/front/src/ui/admin/index.tsx
@@ -0,0 +1 @@
+export * from "./videos-modal";
diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx
new file mode 100644
index 00000000..7d1e5b3c
--- /dev/null
+++ b/front/src/ui/admin/videos-modal.tsx
@@ -0,0 +1,40 @@
+import { View } from "react-native";
+import { entryDisplayNumber } from "~/components/entries";
+import { FullVideo } from "~/models";
+import { Modal, P, Select, Skeleton } from "~/primitives";
+import { InfiniteFetch, type QueryIdentifier } from "~/query";
+import { useQueryState } from "~/utils";
+
+export const VideosModal = () => {
+ const [slug] = useQueryState("slug", undefined!);
+
+ return (
+
+ (
+
+ {item.path}
+
+ )}
+ Loader={() => }
+ />
+
+ );
+};
+
+VideosModal.query = (slug: string): QueryIdentifier => ({
+ parser: FullVideo,
+ path: ["api", "series", slug, "videos"],
+ infinite: true,
+});
From bdbd3933cd0201d7e081169a8329981b3ccf2223 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Sun, 8 Mar 2026 19:42:23 +0100
Subject: [PATCH 05/12] Implement combobox
---
front/bun.lock | 3 +
front/package.json | 1 +
front/src/primitives/combobox.tsx | 193 ++++++++++++++++++++++++++
front/src/primitives/combobox.web.tsx | 185 ++++++++++++++++++++++++
front/src/primitives/index.ts | 1 +
front/src/ui/admin/videos-modal.tsx | 23 +--
6 files changed, 398 insertions(+), 8 deletions(-)
create mode 100644 front/src/primitives/combobox.tsx
create mode 100644 front/src/primitives/combobox.web.tsx
diff --git a/front/bun.lock b/front/bun.lock
index 03e4f9fa..8a4d8112 100644
--- a/front/bun.lock
+++ b/front/bun.lock
@@ -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=="],
diff --git a/front/package.json b/front/package.json
index 376d06d9..534794d5 100644
--- a/front/package.json
+++ b/front/package.json
@@ -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",
diff --git a/front/src/primitives/combobox.tsx b/front/src/primitives/combobox.tsx
new file mode 100644
index 00000000..63197783
--- /dev/null
+++ b/front/src/primitives/combobox.tsx
@@ -0,0 +1,193 @@
+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 { useEffect, useMemo, useRef, useState } from "react";
+import { Pressable, 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";
+
+const useDebounce = (value: T, delay: number): T => {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+ return debounced;
+};
+
+export const ComboBox = ({
+ label,
+ value,
+ onValueChange,
+ query,
+ getLabel,
+ getKey,
+ placeholder,
+ placeholderCount = 4,
+}: {
+ label: string;
+ value: Data | null;
+ onValueChange: (item: Data | null) => void;
+ query: (search: string) => QueryIdentifier;
+ getLabel: (item: Data) => string;
+ getKey: (item: Data) => string;
+ placeholder?: string;
+ placeholderCount?: number;
+}) => {
+ const [isOpen, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const debouncedSearch = useDebounce(search, 300);
+ const inputRef = useRef(null);
+
+ const currentQuery = query(debouncedSearch);
+ const oldItems = useRef(undefined);
+ let { items, fetchNextPage, hasNextPage, isFetching } =
+ useInfiniteFetch(currentQuery);
+ 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 handleSelect = (item: Data) => {
+ onValueChange(item);
+ setOpen(false);
+ setSearch("");
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ setSearch("");
+ };
+
+ return (
+ <>
+ setOpen(true)}
+ accessibilityLabel={label}
+ className={cn(
+ "flex-row items-center justify-center overflow-hidden",
+ "rounded-4xl border-3 border-accent p-1 outline-0",
+ "group focus-within:bg-accent hover:bg-accent",
+ )}
+ >
+
+
+ {value ? getLabel(value) : (placeholder ?? label)}
+
+
+
+
+ {isOpen && (
+
+
+
+
+
+
+
+
+
+ item ? getKey(item) : `placeholder-${index}`
+ }
+ renderItem={({ item }: { item: Data | null }) =>
+ item ? (
+ handleSelect(item)}
+ />
+ ) : (
+
+ )
+ }
+ onEndReached={
+ hasNextPage && !isFetching ? () => fetchNextPage() : undefined
+ }
+ onEndReachedThreshold={0.5}
+ showsVerticalScrollIndicator={false}
+ />
+
+
+ )}
+ >
+ );
+};
+
+const ComboBoxItem = ({
+ label,
+ selected,
+ onSelect,
+}: {
+ label: string;
+ selected: boolean;
+ onSelect: () => void;
+}) => {
+ return (
+
+ {selected && }
+
+ {label}
+
+
+ );
+};
+
+const ComboBoxItemLoader = () => {
+ return (
+
+
+
+ );
+};
diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx
new file mode 100644
index 00000000..419ce6ee
--- /dev/null
+++ b/front/src/primitives/combobox.web.tsx
@@ -0,0 +1,185 @@
+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 { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
+import { cn } from "~/utils";
+import { Icon } from "./icons";
+import { PressableFeedback } from "./links";
+import { InternalTriger } from "./menu.web";
+import { Skeleton } from "./skeleton";
+import { P } from "./text";
+
+export const ComboBox = ({
+ label,
+ value,
+ onValueChange,
+ query,
+ getLabel,
+ getKey,
+ placeholder,
+ placeholderCount = 4,
+}: {
+ label: string;
+ value: Data | null;
+ onValueChange: (item: Data | null) => void;
+ query: (search: string) => QueryIdentifier;
+ getLabel: (item: Data) => string;
+ getKey: (item: Data) => string;
+ placeholder?: string;
+ placeholderCount?: number;
+}) => {
+ const [isOpen, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+
+ const oldItems = useRef(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]);
+
+ return (
+ {
+ setOpen(open);
+ if (!open) setSearch("");
+ }}
+ >
+
+
+
+
+ {value ? getLabel(value) : (placeholder ?? label)}
+
+
+
+
+
+
+ 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)",
+ }}
+ >
+
+
+ setSearch(e.target.value)}
+ placeholder={placeholder ?? label}
+ // 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",
+ )}
+ />
+
+
+ item ? getKey(item) : `placeholder-${index}`
+ }
+ renderItem={({ item }: { item: Data | null }) =>
+ item ? (
+
+ ((item: Data) => {
+ onValueChange(item);
+ setOpen(false);
+ setSearch("");
+ })(item)
+ }
+ />
+ ) : (
+
+ )
+ }
+ onEndReached={
+ hasNextPage && !isFetching ? () => fetchNextPage() : undefined
+ }
+ onEndReachedThreshold={0.5}
+ showsVerticalScrollIndicator={false}
+ style={{ flex: 1, overflow: "auto" as any }}
+ />
+
+
+
+
+ );
+};
+
+const ComboBoxItem = ({
+ label,
+ selected,
+ onSelect,
+}: {
+ label: string;
+ selected: boolean;
+ onSelect: () => void;
+}) => {
+ return (
+
+ );
+};
+
+const ComboBoxItemLoader = () => {
+ return (
+
+
+
+ );
+};
diff --git a/front/src/primitives/index.ts b/front/src/primitives/index.ts
index da067309..134eb4e4 100644
--- a/front/src/primitives/index.ts
+++ b/front/src/primitives/index.ts
@@ -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";
diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx
index 7d1e5b3c..4debee4f 100644
--- a/front/src/ui/admin/videos-modal.tsx
+++ b/front/src/ui/admin/videos-modal.tsx
@@ -1,7 +1,7 @@
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
-import { FullVideo } from "~/models";
-import { Modal, P, Select, Skeleton } from "~/primitives";
+import { Entry, FullVideo } from "~/models";
+import { ComboBox, Modal, P, Skeleton } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { useQueryState } from "~/utils";
@@ -16,14 +16,21 @@ export const VideosModal = () => {
Render={({ item }) => (
{item.path}
-
)}
From 94c9759a43ff2677bf27f7e6903e3527cad63a83 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Sun, 8 Mar 2026 22:25:12 +0100
Subject: [PATCH 06/12] Add multiselect to the combo box
---
front/public/translations/en.json | 4 +-
front/src/primitives/combobox.tsx | 114 ++++++++++++++++----------
front/src/primitives/combobox.web.tsx | 54 +++++++-----
front/src/primitives/modal.tsx | 2 +-
front/src/ui/admin/videos-modal.tsx | 20 +++--
front/src/ui/details/header.tsx | 26 +++---
6 files changed, 138 insertions(+), 82 deletions(-)
diff --git a/front/public/translations/en.json b/front/public/translations/en.json
index 02a90036..8b167e31 100644
--- a/front/public/translations/en.json
+++ b/front/public/translations/en.json
@@ -44,7 +44,9 @@
"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": "No mapping"
},
"browse": {
"mediatypekey": {
diff --git a/front/src/primitives/combobox.tsx b/front/src/primitives/combobox.tsx
index 63197783..fd6a38f2 100644
--- a/front/src/primitives/combobox.tsx
+++ b/front/src/primitives/combobox.tsx
@@ -4,8 +4,8 @@ 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 { useEffect, useMemo, useRef, useState } from "react";
-import { Pressable, TextInput, View } from "react-native";
+import { useMemo, useRef, useState } from "react";
+import { KeyboardAvoidingView, Pressable, TextInput, View } from "react-native";
import { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import { Icon, IconButton } from "./icons";
@@ -13,43 +13,54 @@ import { PressableFeedback } from "./links";
import { Skeleton } from "./skeleton";
import { P } from "./text";
-const useDebounce = (value: T, delay: number): T => {
- const [debounced, setDebounced] = useState(value);
- useEffect(() => {
- const timer = setTimeout(() => setDebounced(value), delay);
- return () => clearTimeout(timer);
- }, [value, delay]);
- return debounced;
+type ComboBoxSingleProps = {
+ multiple?: false;
+ value: Data | null;
+ values?: never;
+ onValueChange: (item: Data | null) => void;
};
+type ComboBoxMultiProps = {
+ multiple: true;
+ value?: never;
+ values: Data[];
+ onValueChange: (items: Data[]) => void;
+};
+
+type ComboBoxBaseProps = {
+ label: string;
+ searchPlaceholder?: string;
+ query: (search: string) => QueryIdentifier;
+ getKey: (item: Data) => string;
+ getLabel: (item: Data) => string;
+ getSmallLabel?: (item: Data) => string;
+ placeholderCount?: number;
+};
+
+export type ComboBoxProps = ComboBoxBaseProps &
+ (ComboBoxSingleProps | ComboBoxMultiProps);
+
export const ComboBox = ({
label,
value,
+ values,
onValueChange,
query,
getLabel,
+ getSmallLabel,
getKey,
- placeholder,
+ searchPlaceholder,
placeholderCount = 4,
-}: {
- label: string;
- value: Data | null;
- onValueChange: (item: Data | null) => void;
- query: (search: string) => QueryIdentifier;
- getLabel: (item: Data) => string;
- getKey: (item: Data) => string;
- placeholder?: string;
- placeholderCount?: number;
-}) => {
+ multiple,
+}: ComboBoxProps) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
- const debouncedSearch = useDebounce(search, 300);
const inputRef = useRef(null);
- const currentQuery = query(debouncedSearch);
const oldItems = useRef(undefined);
- let { items, fetchNextPage, hasNextPage, isFetching } =
- useInfiniteFetch(currentQuery);
+ let { items, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(
+ query(search),
+ );
if (items) oldItems.current = items;
items ??= oldItems.current;
@@ -59,16 +70,10 @@ export const ComboBox = ({
return isFetching ? [...items, ...placeholders] : items;
}, [items, isFetching, placeholderCount]);
- const handleSelect = (item: Data) => {
- onValueChange(item);
- setOpen(false);
- setSearch("");
- };
-
- const handleClose = () => {
- setOpen(false);
- setSearch("");
- };
+ const selectedKeys = useMemo(() => {
+ if (multiple) return new Set(values.map(getKey));
+ return new Set(value !== null ? [getKey(value)] : []);
+ }, [value, values, multiple, getKey]);
return (
<>
@@ -83,7 +88,11 @@ export const ComboBox = ({
>
- {value ? getLabel(value) : (placeholder ?? label)}
+ {(multiple ? !values : !value)
+ ? label
+ : (multiple ? values : [value!])
+ .map(getSmallLabel ?? getLabel)
+ .join(", ")}
({
{isOpen && (
{
+ setOpen(false);
+ setSearch("");
+ }}
tabIndex={-1}
className="absolute inset-0 flex-1 bg-transparent"
/>
- ({
>
{
+ setOpen(false);
+ setSearch("");
+ }}
className="hidden self-end xl:flex"
/>
({
ref={inputRef}
value={search}
onChangeText={setSearch}
- placeholder={placeholder ?? label}
+ placeholder={searchPlaceholder}
autoFocus
textAlignVertical="center"
className="h-full flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400"
@@ -137,8 +153,22 @@ export const ComboBox = ({
item ? (
handleSelect(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)),
+ );
+ }}
/>
) : (
@@ -150,7 +180,7 @@ export const ComboBox = ({
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
/>
-
+
)}
>
diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx
index 419ce6ee..f1f794b5 100644
--- a/front/src/primitives/combobox.web.tsx
+++ b/front/src/primitives/combobox.web.tsx
@@ -5,8 +5,9 @@ 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 { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
+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";
@@ -15,23 +16,17 @@ import { P } from "./text";
export const ComboBox = ({
label,
+ searchPlaceholder,
value,
+ values,
onValueChange,
query,
- getLabel,
getKey,
- placeholder,
+ getLabel,
+ getSmallLabel,
placeholderCount = 4,
-}: {
- label: string;
- value: Data | null;
- onValueChange: (item: Data | null) => void;
- query: (search: string) => QueryIdentifier;
- getLabel: (item: Data) => string;
- getKey: (item: Data) => string;
- placeholder?: string;
- placeholderCount?: number;
-}) => {
+ multiple,
+}: ComboBoxProps) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -48,6 +43,11 @@ export const ComboBox = ({
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 (
({
>
- {value ? getLabel(value) : (placeholder ?? label)}
+ {(multiple ? !values : !value)
+ ? label
+ : (multiple ? values : [value!])
+ .map(getSmallLabel ?? getLabel)
+ .join(", ")}
({
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
- placeholder={placeholder ?? label}
+ placeholder={searchPlaceholder}
// biome-ignore lint/a11y/noAutofocus: combobox search should auto-focus on open
autoFocus
className={cn(
@@ -115,14 +119,22 @@ export const ComboBox = ({
item ? (
- ((item: Data) => {
+ selected={selectedKeys.has(getKey(item))}
+ onSelect={() => {
+ if (!multiple) {
onValueChange(item);
setOpen(false);
- setSearch("");
- })(item)
- }
+ return;
+ }
+
+ if (!selectedKeys.has(getKey(item))) {
+ onValueChange([...values, item]);
+ return;
+ }
+ onValueChange(
+ values.filter((v) => getKey(v) !== getKey(item)),
+ );
+ }}
/>
) : (
diff --git a/front/src/primitives/modal.tsx b/front/src/primitives/modal.tsx
index 0d8f7171..6cc13f54 100644
--- a/front/src/primitives/modal.tsx
+++ b/front/src/primitives/modal.tsx
@@ -37,7 +37,7 @@ export const Modal = ({
e.preventDefault()}
>
diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx
index 4debee4f..a92b9719 100644
--- a/front/src/ui/admin/videos-modal.tsx
+++ b/front/src/ui/admin/videos-modal.tsx
@@ -1,15 +1,19 @@
+import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
import { Entry, FullVideo } from "~/models";
import { ComboBox, Modal, P, Skeleton } from "~/primitives";
-import { InfiniteFetch, type QueryIdentifier } from "~/query";
+import { InfiniteFetch, type QueryIdentifier, useFetch } from "~/query";
import { useQueryState } from "~/utils";
+import { Header } from "../details/header";
export const VideosModal = () => {
const [slug] = useQueryState("slug", undefined!);
+ const { data } = useFetch(Header.query("serie", slug));
+ const { t } = useTranslation();
return (
-
+
{
{item.path}
entryDisplayNumber(x)).join(", ")}
+ multiple
+ label={t("show.videos-map-none")}
+ searchPlaceholder={t("navbar.search")}
+ values={item.entries}
query={(q) => ({
parser: Entry,
path: ["api", "series", slug, "entries"],
@@ -28,9 +33,10 @@ export const VideosModal = () => {
},
infinite: true,
})}
- getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
- onValueChange={(x) => {}}
getKey={(x) => x.id}
+ getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
+ getSmallLabel={entryDisplayNumber}
+ onValueChange={(x) => {}}
/>
)}
diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx
index eb0be504..7ac007c2 100644
--- a/front/src/ui/details/header.tsx
+++ b/front/src/ui/details/header.tsx
@@ -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" &&
} */}
- {/* metadataRefreshMutation.mutate()} */}
- {/* /> */}
- {/* > */}
- {/* )} */}
+ {account?.isAdmin === true && (
+ <>
+ {kind === "movie" &&
}
+
+ {/* metadataRefreshMutation.mutate()} */}
+ {/* /> */}
+ >
+ )}
)}
From 3890e39e567739fad75b6a6ebd2371df39652dc3 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Mon, 9 Mar 2026 11:49:07 +0100
Subject: [PATCH 07/12] Add put and delete to link videos
---
api/src/controllers/seed/video-links.ts | 314 ++++++++++++++++++++++++
api/src/controllers/seed/videos.ts | 228 +----------------
2 files changed, 318 insertions(+), 224 deletions(-)
create mode 100644 api/src/controllers/seed/video-links.ts
diff --git a/api/src/controllers/seed/video-links.ts b/api/src/controllers/seed/video-links.ts
new file mode 100644
index 00000000..afb20854
--- /dev/null
+++ b/api/src/controllers/seed/video-links.ts
@@ -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 & {
+ 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,
+ );
+
+ 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] })),
+ },
+ },
+ );
diff --git a/api/src/controllers/seed/videos.ts b/api/src/controllers/seed/videos.ts
index 97473ae1..7bec4546 100644
--- a/api/src/controllers/seed/videos.ts
+++ b/api/src/controllers/seed/videos.ts
@@ -1,14 +1,12 @@
-import { and, eq, gt, ne, notExists, or, sql } from "drizzle-orm";
-import { alias } from "drizzle-orm/pg-core";
+import { and, eq, notExists, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { auth } from "~/auth";
-import { db, type Transaction } from "~/db";
-import { entries, entryVideoJoin, shows, videos } from "~/db/schema";
+import { db } from "~/db";
+import { entries, entryVideoJoin, videos } from "~/db/schema";
import {
conflictUpdateAllExcept,
isUniqueConstraint,
sqlarr,
- unnest,
unnestValues,
} from "~/db/utils";
import { KError } from "~/models/error";
@@ -16,155 +14,8 @@ import { bubbleVideo } from "~/models/examples";
import { isUuid } from "~/models/utils";
import { Guess, SeedVideo, Video } from "~/models/video";
import { comment } from "~/utils";
-import { computeVideoSlug } from "./insert/entries";
import { updateAvailableCount, updateAvailableSince } from "./insert/shows";
-
-async function linkVideos(
- tx: Transaction,
- links: {
- video: number;
- entry: Omit & {
- 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,
- );
-
- 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;
-}
+import { linkVideos } from "./video-links";
const CreatedVideo = t.Object({
id: t.String({ format: "uuid" }),
@@ -345,75 +196,4 @@ export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] })
),
response: { 200: t.Array(t.String()) },
},
- )
- .post(
- "/link",
- async ({ body, status }) => {
- return await db.transaction(async (tx) => {
- 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 lVids = body.flatMap((x) => {
- return 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,
- },
- }));
- });
- const links = await linkVideos(tx, lVids);
- 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: t.Array(
- t.Object({
- id: t.String({
- description: "Id of the video",
- format: "uuid",
- }),
- for: t.Array(SeedVideo.properties.for.items),
- }),
- ),
- response: {
- 201: 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"],
- }),
- }),
- ),
- }),
- ),
- 422: KError,
- },
- },
);
From ef1486deafb3a419d4f4ac92c711868b29ef2cf9 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Mon, 9 Mar 2026 17:31:06 +0100
Subject: [PATCH 08/12] Update video's link on comboxbox change
---
api/src/base.ts | 3 +-
api/src/controllers/videos.ts | 2 +-
front/src/primitives/combobox.tsx | 1 +
front/src/primitives/combobox.web.tsx | 1 +
front/src/query/fetch-infinite.tsx | 4 +--
front/src/query/query.tsx | 14 +++++++--
front/src/ui/admin/videos-modal.tsx | 45 +++++++++++++++++++++++++--
7 files changed, 61 insertions(+), 9 deletions(-)
diff --git a/api/src/base.ts b/api/src/base.ts
index b6cf5e8b..d6db13a7 100644
--- a/api/src/base.ts
+++ b/api/src/base.ts
@@ -8,6 +8,7 @@ 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";
@@ -140,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),
);
diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts
index 983e91c7..dd67d3b5 100644
--- a/api/src/controllers/videos.ts
+++ b/api/src/controllers/videos.ts
@@ -79,7 +79,7 @@ const videoSort = Sort(
],
},
{
- default: ["entry"],
+ default: ["entry", "path"],
tablePk: videos.pk,
},
);
diff --git a/front/src/primitives/combobox.tsx b/front/src/primitives/combobox.tsx
index fd6a38f2..cc9f58ad 100644
--- a/front/src/primitives/combobox.tsx
+++ b/front/src/primitives/combobox.tsx
@@ -91,6 +91,7 @@ export const ComboBox = ({
{(multiple ? !values : !value)
? label
: (multiple ? values : [value!])
+ .sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx
index f1f794b5..822d0e47 100644
--- a/front/src/primitives/combobox.web.tsx
+++ b/front/src/primitives/combobox.web.tsx
@@ -70,6 +70,7 @@ export const ComboBox = ({
{(multiple ? !values : !value)
? label
: (multiple ? values : [value!])
+ .sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
diff --git a/front/src/query/fetch-infinite.tsx b/front/src/query/fetch-infinite.tsx
index 1ebd45e1..a3a0fbdd 100644
--- a/front/src/query/fetch-infinite.tsx
+++ b/front/src/query/fetch-infinite.tsx
@@ -68,8 +68,8 @@ export const InfiniteFetch = ({
: 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;
diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx
index 705970fd..ca402a50 100644
--- a/front/src/query/query.tsx
+++ b/front/src/query/query.tsx
@@ -322,10 +322,12 @@ export const useMutation = ({
compute,
invalidate,
optimistic,
+ optimisticKey,
...queryParams
}: MutationParams & {
compute?: (param: T) => MutationParams;
optimistic?: (param: T, previous?: QueryRet) => QueryRet | undefined;
+ optimisticKey?: QueryIdentifier;
invalidate: string[] | null;
}) => {
const { apiUrl, authToken } = useContext(AccountContext);
@@ -348,7 +350,11 @@ export const useMutation = ({
...(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 = ({
},
onError: (_, __, context) => {
queryClient.setQueryData(
- toQueryKey({ apiUrl, path: invalidate }),
+ toQueryKey({
+ apiUrl,
+ path: optimisticKey?.path ?? invalidate,
+ params: optimisticKey?.params,
+ }),
context!.previous,
);
},
diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx
index a92b9719..967ddf4f 100644
--- a/front/src/ui/admin/videos-modal.tsx
+++ b/front/src/ui/admin/videos-modal.tsx
@@ -1,9 +1,14 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
-import { Entry, FullVideo } from "~/models";
+import { Entry, FullVideo, type Page } from "~/models";
import { ComboBox, Modal, P, Skeleton } from "~/primitives";
-import { InfiniteFetch, type QueryIdentifier, useFetch } from "~/query";
+import {
+ InfiniteFetch,
+ type QueryIdentifier,
+ useFetch,
+ useMutation,
+} from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "../details/header";
@@ -12,6 +17,32 @@ export const VideosModal = () => {
const { data } = useFetch(Header.query("serie", slug));
const { t } = useTranslation();
+ const { mutateAsync } = useMutation({
+ method: "PUT",
+ path: ["api", "videos", "link"],
+ compute: ({
+ video,
+ entries,
+ }: {
+ video: string;
+ entries: Omit[];
+ }) => ({
+ body: [{ id: video, for: entries.map((x) => ({ slug: x.slug })) }],
+ }),
+ invalidate: ["api", "series", slug],
+ optimisticKey: VideosModal.query(slug),
+ optimistic: (params, prev?: { pages: Page[] }) => ({
+ ...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 (
{
getKey={(x) => x.id}
getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
getSmallLabel={entryDisplayNumber}
- onValueChange={(x) => {}}
+ onValueChange={async (entries) => {
+ await mutateAsync({
+ video: item.id,
+ entries,
+ });
+ }}
/>
)}
@@ -49,5 +85,8 @@ export const VideosModal = () => {
VideosModal.query = (slug: string): QueryIdentifier => ({
parser: FullVideo,
path: ["api", "series", slug, "videos"],
+ params: {
+ sort: "path",
+ },
infinite: true,
});
From 32bfdb0ce0e57ff182ce2055bf655afa0c950365 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Tue, 10 Mar 2026 13:53:55 +0100
Subject: [PATCH 09/12] Add put routes for videos
---
api/src/controllers/seed/videos.ts | 201 ++++++++++++++++----------
api/src/controllers/videos.ts | 25 +++-
front/public/translations/en.json | 6 +-
front/src/models/video.ts | 42 +++---
front/src/primitives/button.tsx | 7 +-
front/src/primitives/combobox.tsx | 67 +++++----
front/src/primitives/combobox.web.tsx | 53 +++----
front/src/primitives/links.tsx | 10 +-
front/src/query/fetch-infinite.tsx | 3 +
front/src/ui/admin/videos-modal.tsx | 129 +++++++++++++----
10 files changed, 353 insertions(+), 190 deletions(-)
diff --git a/api/src/controllers/seed/videos.ts b/api/src/controllers/seed/videos.ts
index 7bec4546..ba834b46 100644
--- a/api/src/controllers/seed/videos.ts
+++ b/api/src/controllers/seed/videos.ts
@@ -28,6 +28,91 @@ const CreatedVideo = t.Object({
),
});
+async function createVideos(body: SeedVideo[], clearLinks: boolean) {
+ if (body.length === 0) {
+ return { status: 422, message: "No videos" } as const;
+ }
+ return await db.transaction(async (tx) => {
+ let vids: { pk: number; id: string; path: string; guess: Guess }[] = [];
+ try {
+ vids = await tx
+ .insert(videos)
+ .select(unnestValues(body, videos))
+ .onConflictDoUpdate({
+ target: [videos.path],
+ set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]),
+ })
+ .returning({
+ pk: videos.pk,
+ id: videos.id,
+ path: videos.path,
+ guess: videos.guess,
+ });
+ } catch (e) {
+ if (!isUniqueConstraint(e)) throw e;
+ return {
+ status: 409,
+ message: comment`
+ Invalid rendering. A video with the same (rendering, part, version) combo
+ (but with a different path) already exists in db.
+
+ rendering should be computed by the sha of your path (excluding only the version & part numbers)
+ `,
+ } as const;
+ }
+
+ const vidEntries = body.flatMap((x) => {
+ if (!x.for) return [];
+ return x.for.map((e) => ({
+ video: vids.find((v) => v.path === x.path)!.pk,
+ entry: {
+ ...e,
+ movie:
+ "movie" in e
+ ? isUuid(e.movie)
+ ? { id: e.movie }
+ : { slug: e.movie }
+ : undefined,
+ serie:
+ "serie" in e
+ ? isUuid(e.serie)
+ ? { id: e.serie }
+ : { slug: e.serie }
+ : undefined,
+ },
+ }));
+ });
+
+ if (!vidEntries.length) {
+ return vids.map((x) => ({
+ id: x.id,
+ path: x.path,
+ guess: x.guess,
+ entries: [],
+ }));
+ }
+
+ if (clearLinks) {
+ await tx
+ .delete(entryVideoJoin)
+ .where(
+ eq(
+ entryVideoJoin.videoPk,
+ sql`any(${sqlarr(vids.map((x) => x.pk))})`,
+ ),
+ );
+ }
+ const links = await linkVideos(tx, vidEntries);
+
+ return vids.map((x) => ({
+ id: x.id,
+ path: x.path,
+ guess: x.guess,
+ entries: links[x.pk] ?? [],
+ }));
+ });
+}
+
export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.model({
video: Video,
@@ -38,84 +123,9 @@ export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] })
.post(
"",
async ({ body, status }) => {
- if (body.length === 0) {
- return status(422, { status: 422, message: "No videos" });
- }
- return await db.transaction(async (tx) => {
- let vids: { pk: number; id: string; path: string; guess: Guess }[] = [];
- try {
- vids = await tx
- .insert(videos)
- .select(unnestValues(body, videos))
- .onConflictDoUpdate({
- target: [videos.path],
- set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]),
- })
- .returning({
- pk: videos.pk,
- id: videos.id,
- path: videos.path,
- guess: videos.guess,
- });
- } catch (e) {
- if (!isUniqueConstraint(e)) throw e;
- return status(409, {
- status: 409,
- message: comment`
- Invalid rendering. A video with the same (rendering, part, version) combo
- (but with a different path) already exists in db.
-
- rendering should be computed by the sha of your path (excluding only the version & part numbers)
- `,
- });
- }
-
- const vidEntries = body.flatMap((x) => {
- if (!x.for) return [];
- return x.for.map((e) => ({
- video: vids.find((v) => v.path === x.path)!.pk,
- entry: {
- ...e,
- movie:
- "movie" in e
- ? isUuid(e.movie)
- ? { id: e.movie }
- : { slug: e.movie }
- : undefined,
- serie:
- "serie" in e
- ? isUuid(e.serie)
- ? { id: e.serie }
- : { slug: e.serie }
- : undefined,
- },
- }));
- });
-
- if (!vidEntries.length) {
- return status(
- 201,
- vids.map((x) => ({
- id: x.id,
- path: x.path,
- guess: x.guess,
- entries: [],
- })),
- );
- }
-
- const links = await linkVideos(tx, vidEntries);
-
- return status(
- 201,
- vids.map((x) => ({
- id: x.id,
- path: x.path,
- guess: x.guess,
- entries: links[x.pk] ?? [],
- })),
- );
- });
+ const ret = await createVideos(body, false);
+ if ("status" in ret) return status(ret.status, ret);
+ return status(201, ret);
},
{
detail: {
@@ -123,8 +133,39 @@ export const videosWriteH = new Elysia({ prefix: "/videos", tags: ["videos"] })
Create videos in bulk.
Duplicated videos will simply be ignored.
- If a videos has a \`guess\` field, it will be used to automatically register the video under an existing
- movie or entry.
+ The \`for\` field of each video can be used to link the video to an existing entry.
+
+ If the video was already registered, links will be merged (existing and new ones will be kept).
+ `,
+ },
+ body: t.Array(SeedVideo),
+ response: {
+ 201: t.Array(CreatedVideo),
+ 409: {
+ ...KError,
+ description:
+ "Invalid rendering specified. (conflicts with an existing video)",
+ },
+ 422: KError,
+ },
+ },
+ )
+ .put(
+ "",
+ async ({ body, status }) => {
+ const ret = await createVideos(body, true);
+ if ("status" in ret) return status(ret.status, ret);
+ return status(201, ret);
+ },
+ {
+ detail: {
+ description: comment`
+ Create videos in bulk.
+ Duplicated videos will simply be ignored.
+
+ The \`for\` field of each video can be used to link the video to an existing entry.
+
+ If the video was already registered, links will be overriden (existing will be removed and new ones will be created).
`,
},
body: t.Array(SeedVideo),
diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts
index dd67d3b5..93d610c6 100644
--- a/api/src/controllers/videos.ts
+++ b/api/src/controllers/videos.ts
@@ -11,6 +11,8 @@ import {
or,
type SQL,
sql,
+ inArray,
+ WithSubquery,
} from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { Elysia, t } from "elysia";
@@ -275,6 +277,7 @@ export async function getVideos({
preferOriginal = false,
relations = [],
userId,
+ cte = [],
}: {
after?: string;
limit: number;
@@ -285,8 +288,10 @@ export async function getVideos({
preferOriginal?: boolean;
relations?: (keyof typeof videoRelations)[];
userId: string;
+ cte?: WithSubquery[];
}) {
let ret = await db
+ .with(...cte)
.select({
...getColumns(videos),
...buildRelations(relations, videoRelations, {
@@ -620,9 +625,27 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
});
}
+ const titleGuess = db.$with("title_guess").as(
+ db
+ .selectDistinctOn([sql`${videos.guess}->>'title'`], {
+ title: sql`${videos.guess}->>'title'`.as("title"),
+ })
+ .from(videos)
+ .leftJoin(evJoin, eq(videos.pk, evJoin.videoPk))
+ .leftJoin(entries, eq(entries.pk, evJoin.entryPk))
+ .where(eq(entries.showPk, serie.pk)),
+ );
+
const languages = processLanguages(langs);
const items = await getVideos({
- filter: eq(entries.showPk, serie.pk),
+ cte: [titleGuess],
+ filter: or(
+ eq(entries.showPk, serie.pk),
+ inArray(
+ sql`${videos.guess}->>'title'`,
+ db.select().from(titleGuess),
+ ),
+ ),
limit,
after,
query,
diff --git a/front/public/translations/en.json b/front/public/translations/en.json
index 8b167e31..cc7e645b 100644
--- a/front/public/translations/en.json
+++ b/front/public/translations/en.json
@@ -22,8 +22,7 @@
"staff": "Staff",
"staff-none": "The staff is unknown",
"noOverview": "No overview available",
- "episode-none": "There is no episodes in this season",
- "episodeNoMetadata": "No metadata available",
+ "episode-none": "There is no episodes in this season", "episodeNoMetadata": "No metadata available",
"tags": "Tags",
"tags-none": "No tags available",
"links": "Links",
@@ -46,7 +45,8 @@
"multiVideos": "Multiples video files available",
"videosCount": "{{number}} videos",
"videos-map": "Edit video mappings",
- "videos-map-none": "No mapping"
+ "videos-map-none": "NONE",
+ "videos-map-add": "Add another video"
},
"browse": {
"mediatypekey": {
diff --git a/front/src/models/video.ts b/front/src/models/video.ts
index 80fa5f3f..1632cceb 100644
--- a/front/src/models/video.ts
+++ b/front/src/models/video.ts
@@ -4,34 +4,32 @@ import { Extra } from "./extra";
import { Show } from "./show";
import { zdate } from "./utils/utils";
+export const Guess = z.looseObject({
+ title: z.string(),
+ kind: z.enum(["episode", "movie", "extra"]).nullable().optional(),
+ extraKind: Extra.shape.kind.optional().nullable(),
+ years: z.array(z.int()).default([]),
+ episodes: z
+ .array(
+ z.object({
+ season: z.int().nullable(),
+ episode: z.int(),
+ }),
+ )
+ .default([]),
+ externalId: z.record(z.string(), z.string()).default({}),
+
+ // Name of the tool that made the guess
+ from: z.string(),
+});
+
export const Video = z.object({
id: z.string(),
path: z.string(),
rendering: z.string(),
part: z.int().min(0).nullable(),
version: z.int().min(0).default(1),
- guess: z.object({
- title: z.string(),
- kind: z.enum(["episode", "movie", "extra"]).nullable().optional(),
- extraKind: Extra.shape.kind.optional().nullable(),
- years: z.array(z.int()).default([]),
- episodes: z
- .array(
- z.object({
- season: z.int().nullable(),
- episode: z.int(),
- }),
- )
- .default([]),
- externalId: z.record(z.string(), z.string()).default({}),
-
- // Name of the tool that made the guess
- from: z.string(),
- // Adding that results in an infinite recursion
- // get history() {
- // return z.array(Video.shape.guess.omit({ history: true })).default([]);
- // },
- }),
+ guess: Guess.extend({ history: z.array(Guess).default([]) }),
createdAt: zdate(),
updatedAt: zdate(),
});
diff --git a/front/src/primitives/button.tsx b/front/src/primitives/button.tsx
index 15aff478..d380e8b4 100644
--- a/front/src/primitives/button.tsx
+++ b/front/src/primitives/button.tsx
@@ -1,7 +1,6 @@
import type { ComponentProps, ComponentType, Ref } from "react";
import {
type Falsy,
- type Pressable,
type PressableProps,
View,
} from "react-native";
@@ -20,18 +19,18 @@ export const Button = ({
className,
...props
}: {
- disabled?: boolean;
+ disabled?: boolean | null;
text?: string;
icon?: ComponentProps["icon"] | Falsy;
ricon?: ComponentProps["icon"] | Falsy;
- ref?: Ref;
+ ref?: Ref;
className?: string;
as?: ComponentType;
} & AsProps) => {
const Container = as ?? PressableFeedback;
return (
= {
};
type ComboBoxBaseProps = {
- label: string;
searchPlaceholder?: string;
query: (search: string) => QueryIdentifier;
getKey: (item: Data) => string;
getLabel: (item: Data) => string;
getSmallLabel?: (item: Data) => string;
placeholderCount?: number;
+ label?: string;
+ Trigger?: ComponentType;
};
export type ComboBoxProps = ComboBoxBaseProps &
@@ -52,6 +59,7 @@ export const ComboBox = ({
searchPlaceholder,
placeholderCount = 4,
multiple,
+ Trigger,
}: ComboBoxProps) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -77,30 +85,34 @@ export const ComboBox = ({
return (
<>
- setOpen(true)}
- accessibilityLabel={label}
- className={cn(
- "flex-row items-center justify-center overflow-hidden",
- "rounded-4xl border-3 border-accent p-1 outline-0",
- "group focus-within:bg-accent hover:bg-accent",
- )}
- >
-
-
- {(multiple ? !values : !value)
- ? label
- : (multiple ? values : [value!])
- .sort((a, b) => getKey(a).localeCompare(getKey(b)))
- .map(getSmallLabel ?? getLabel)
- .join(", ")}
-
-
-
-
+ {Trigger ? (
+ setOpen(true)} />
+ ) : (
+ setOpen(true)}
+ accessibilityLabel={label}
+ className={cn(
+ "flex-row items-center justify-center overflow-hidden",
+ "rounded-4xl border-3 border-accent p-1 outline-0",
+ "group focus-within:bg-accent hover:bg-accent",
+ )}
+ >
+
+
+ {(multiple ? !values?.length : !value)
+ ? label
+ : (multiple ? values : [value!])
+ .sort((a, b) => getKey(a).localeCompare(getKey(b)))
+ .map(getSmallLabel ?? getLabel)
+ .join(", ")}
+
+
+
+
+ )}
{isOpen && (
({
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
- showsVerticalScrollIndicator={false}
/>
diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx
index 822d0e47..f2462ea9 100644
--- a/front/src/primitives/combobox.web.tsx
+++ b/front/src/primitives/combobox.web.tsx
@@ -26,6 +26,7 @@ export const ComboBox = ({
getSmallLabel,
placeholderCount = 4,
multiple,
+ Trigger,
}: ComboBoxProps) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -57,29 +58,33 @@ export const ComboBox = ({
}}
>
-
-
-
- {(multiple ? !values : !value)
- ? label
- : (multiple ? values : [value!])
- .sort((a, b) => getKey(a).localeCompare(getKey(b)))
- .map(getSmallLabel ?? getLabel)
- .join(", ")}
-
-
-
-
+ {Trigger ? (
+
+ ) : (
+
+
+
+ {(multiple ? !values.length : !value)
+ ? label
+ : (multiple ? values : [value!])
+ .sort((a, b) => getKey(a).localeCompare(getKey(b)))
+ .map(getSmallLabel ?? getLabel)
+ .join(", ")}
+
+
+
+
+ )}
({
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
- showsVerticalScrollIndicator={false}
- style={{ flex: 1, overflow: "auto" as any }}
/>
diff --git a/front/src/primitives/links.tsx b/front/src/primitives/links.tsx
index f6003a08..a1f27ce0 100644
--- a/front/src/primitives/links.tsx
+++ b/front/src/primitives/links.tsx
@@ -1,11 +1,12 @@
import { useRouter } from "expo-router";
-import type { ReactNode } from "react";
+import type { ReactNode, RefObject } from "react";
import {
Linking,
Platform,
Pressable,
type PressableProps,
type TextProps,
+ type View,
} from "react-native";
import { useResolveClassNames } from "uniwind";
import { cn } from "~/utils";
@@ -70,11 +71,16 @@ export const A = ({
);
};
-export const PressableFeedback = ({ children, ...props }: PressableProps) => {
+export const PressableFeedback = ({
+ children,
+ ref,
+ ...props
+}: PressableProps & { ref?: RefObject }) => {
const { color } = useResolveClassNames("text-slate-400/25");
return (
({
Empty,
Divider,
Header,
+ Footer,
fetchMore = true,
contentContainerStyle,
columnWrapperStyle,
@@ -45,6 +46,7 @@ export const InfiniteFetch = ({
incremental?: boolean;
Divider?: true | ComponentType;
Header?: ComponentType<{ children: JSX.Element }> | ReactElement;
+ Footer?: ComponentType<{ children: JSX.Element }> | ReactElement;
fetchMore?: boolean;
contentContainerStyle?: ViewStyle;
onScroll?: LegendListProps["onScroll"];
@@ -100,6 +102,7 @@ export const InfiniteFetch = ({
onRefresh={layout.layout !== "horizontal" ? refetch : undefined}
refreshing={isRefetching}
ListHeaderComponent={Header}
+ ListFooterComponent={Footer}
ItemSeparatorComponent={
Divider === true ? HR : (Divider as any) || undefined
}
diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx
index 967ddf4f..8915d802 100644
--- a/front/src/ui/admin/videos-modal.tsx
+++ b/front/src/ui/admin/videos-modal.tsx
@@ -1,8 +1,10 @@
+import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
+import LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
import { Entry, FullVideo, type Page } from "~/models";
-import { ComboBox, Modal, P, Skeleton } from "~/primitives";
+import { Button, ComboBox, IconButton, Modal, P, Skeleton } from "~/primitives";
import {
InfiniteFetch,
type QueryIdentifier,
@@ -17,7 +19,7 @@ export const VideosModal = () => {
const { data } = useFetch(Header.query("serie", slug));
const { t } = useTranslation();
- const { mutateAsync } = useMutation({
+ const { mutateAsync: editLinks } = useMutation({
method: "PUT",
path: ["api", "videos", "link"],
compute: ({
@@ -42,6 +44,53 @@ export const VideosModal = () => {
})),
}),
});
+ const { mutateAsync: editGuess } = useMutation({
+ method: "PUT",
+ path: ["api", "videos"],
+ compute: (video: FullVideo) => ({
+ body: [
+ {
+ ...video,
+ guess: {
+ ...video.guess,
+ title: data?.name ?? slug,
+ from: "manual-edit",
+ history: [...video.guess.history, video.guess],
+ },
+ for: video.guess.episodes.map((x) => ({
+ serie: slug,
+ season: x.season,
+ episode: x.episode,
+ })),
+ entries: undefined,
+ },
+ ],
+ }),
+ invalidate: ["api", "series", "slug"],
+ optimisticKey: VideosModal.query(slug),
+ optimistic: (params, prev?: { pages: Page[] }) => ({
+ ...prev!,
+ pages: prev!.pages.map((p, i) => {
+ const idx = p.items.findIndex(
+ (x) => params.path.localeCompare(x.path) < 0,
+ );
+ if (idx !== -1) {
+ return {
+ ...p,
+ items: [
+ ...p.items.slice(0, idx),
+ params,
+ ...p.items.slice(idx, -1),
+ ],
+ };
+ }
+ if (i === prev!.pages.length) {
+ return { ...p, items: [...p.items, params] };
+ }
+ return p;
+ }),
+ }),
+ });
return (
@@ -51,32 +100,62 @@ export const VideosModal = () => {
Render={({ item }) => (
{item.path}
- ({
- parser: Entry,
- path: ["api", "series", slug, "entries"],
- params: {
- query: q,
- },
- infinite: true,
- })}
- getKey={(x) => x.id}
- getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
- getSmallLabel={entryDisplayNumber}
- onValueChange={async (entries) => {
- await mutateAsync({
- video: item.id,
- entries,
- });
- }}
- />
+
+ ({
+ parser: Entry,
+ path: ["api", "series", slug, "entries"],
+ params: {
+ query: q,
+ },
+ infinite: true,
+ })}
+ getKey={(x) => x.id}
+ getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
+ getSmallLabel={entryDisplayNumber}
+ onValueChange={async (entries) => {
+ await editLinks({
+ video: item.id,
+ entries,
+ });
+ }}
+ />
+ {}} />
+
)}
Loader={() => }
+ Footer={
+ (
+
+ )}
+ 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!)}
+ />
+ }
/>
);
From 8f82a039894415267d63dee619cd697407a543b0 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Wed, 11 Mar 2026 12:00:38 +0100
Subject: [PATCH 10/12] Allow guesses to be validated/unvalidated
---
api/src/controllers/seed/videos.ts | 2 +-
api/src/controllers/videos.ts | 27 +++-
front/public/translations/en.json | 9 +-
front/src/primitives/modal.tsx | 10 +-
front/src/ui/admin/videos-modal.tsx | 188 ++++++++++++++++++----------
front/src/ui/details/season.tsx | 1 -
front/src/utils.ts | 10 ++
7 files changed, 174 insertions(+), 73 deletions(-)
diff --git a/api/src/controllers/seed/videos.ts b/api/src/controllers/seed/videos.ts
index ba834b46..12a93a4c 100644
--- a/api/src/controllers/seed/videos.ts
+++ b/api/src/controllers/seed/videos.ts
@@ -14,7 +14,7 @@ import { bubbleVideo } from "~/models/examples";
import { isUuid } from "~/models/utils";
import { Guess, SeedVideo, Video } from "~/models/video";
import { comment } from "~/utils";
-import { updateAvailableCount, updateAvailableSince } from "./insert/shows";
+import { updateAvailableCount } from "./insert/shows";
import { linkVideos } from "./video-links";
const CreatedVideo = t.Object({
diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts
index 93d610c6..ddfdd073 100644
--- a/api/src/controllers/videos.ts
+++ b/api/src/controllers/videos.ts
@@ -33,6 +33,7 @@ import {
jsonbBuildObject,
jsonbObjectAgg,
sqlarr,
+ unnest,
} from "~/db/utils";
import type { Entry } from "~/models/entry";
import { KError } from "~/models/error";
@@ -56,7 +57,7 @@ import {
import { desc as description } from "~/models/utils/descriptions";
import { Guesses, Video } from "~/models/video";
import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist";
-import { uniqBy } from "~/utils";
+import { comment, uniqBy } from "~/utils";
import {
entryProgressQ,
entryVideosQ,
@@ -601,7 +602,7 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
"series/:id/videos",
async ({
params: { id },
- query: { limit, after, query, sort, preferOriginal },
+ query: { limit, after, query, sort, preferOriginal, titles },
headers: { "accept-language": langs, ...headers },
request: { url },
jwt: { sub, settings },
@@ -633,7 +634,12 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
.from(videos)
.leftJoin(evJoin, eq(videos.pk, evJoin.videoPk))
.leftJoin(entries, eq(entries.pk, evJoin.entryPk))
- .where(eq(entries.showPk, serie.pk)),
+ .where(eq(entries.showPk, serie.pk))
+ .union(
+ db
+ .select({ title: sql`title` })
+ .from(sql`unnest(${sqlarr(titles ?? [])}::text[]) as title`),
+ ),
);
const languages = processLanguages(langs);
@@ -654,6 +660,11 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
preferOriginal: preferOriginal ?? settings.preferOriginal,
userId: sub,
});
+ for (const i of items)
+ i.entries = i.entries.filter(
+ (x) =>
+ (x as unknown as typeof entries.$inferSelect).showPk === serie.pk,
+ );
return createPage(items, { url, sort, limit, headers });
},
{
@@ -679,6 +690,16 @@ export const videosReadH = new Elysia({ tags: ["videos"] })
description: description.preferOriginal,
}),
),
+ titles: t.Optional(
+ t.Array(
+ t.String({
+ description: comment`
+ Return videos in the serie + videos with a title
+ guess equal to one of the element of this list
+ `,
+ }),
+ ),
+ ),
}),
headers: t.Object({
"accept-language": AcceptLanguage(),
diff --git a/front/public/translations/en.json b/front/public/translations/en.json
index cc7e645b..c7424a2f 100644
--- a/front/public/translations/en.json
+++ b/front/public/translations/en.json
@@ -22,7 +22,8 @@
"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 +47,11 @@
"videosCount": "{{number}} videos",
"videos-map": "Edit video mappings",
"videos-map-none": "NONE",
- "videos-map-add": "Add another video"
+ "videos-map-add": "Add another video",
+ "videos-map-delete": "Remove video from serie",
+ "videos-map-validate": "Validate guess and add video to serie",
+ "videos-map-no-guess": "This video was guessed to be part of this serie but we don't know which episode",
+ "videos-map-related": "Added videos related to the title {{title}}"
},
"browse": {
"mediatypekey": {
diff --git a/front/src/primitives/modal.tsx b/front/src/primitives/modal.tsx
index 6cc13f54..e614a961 100644
--- a/front/src/primitives/modal.tsx
+++ b/front/src/primitives/modal.tsx
@@ -37,11 +37,11 @@ export const Modal = ({
e.preventDefault()}
>
-
+
{title}
- {scroll ? {children} : children}
+ {scroll ? (
+ {children}
+ ) : (
+ {children}
+ )}
>
diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx
index 8915d802..767f60d4 100644
--- a/front/src/ui/admin/videos-modal.tsx
+++ b/front/src/ui/admin/videos-modal.tsx
@@ -1,10 +1,21 @@
+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 LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
import { Entry, FullVideo, type Page } from "~/models";
-import { Button, ComboBox, IconButton, Modal, P, Skeleton } from "~/primitives";
+import {
+ Button,
+ ComboBox,
+ IconButton,
+ Modal,
+ P,
+ Skeleton,
+ tooltip,
+} from "~/primitives";
import {
InfiniteFetch,
type QueryIdentifier,
@@ -13,11 +24,13 @@ import {
} from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "../details/header";
+import { uniqBy } from "~/utils";
export const VideosModal = () => {
const [slug] = useQueryState("slug", undefined!);
const { data } = useFetch(Header.query("serie", slug));
const { t } = useTranslation();
+ const [titles, setTitles] = useState([]);
const { mutateAsync: editLinks } = useMutation({
method: "PUT",
@@ -25,14 +38,31 @@ export const VideosModal = () => {
compute: ({
video,
entries,
+ guess = false,
}: {
video: string;
entries: Omit[];
+ guess?: boolean;
}) => ({
- body: [{ id: video, for: entries.map((x) => ({ slug: x.slug })) }],
+ 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),
+ optimisticKey: VideosModal.query(slug, null),
optimistic: (params, prev?: { pages: Page[] }) => ({
...prev!,
pages: prev!.pages.map((p) => ({
@@ -44,68 +74,85 @@ export const VideosModal = () => {
})),
}),
});
- const { mutateAsync: editGuess } = useMutation({
- method: "PUT",
- path: ["api", "videos"],
- compute: (video: FullVideo) => ({
- body: [
- {
- ...video,
- guess: {
- ...video.guess,
- title: data?.name ?? slug,
- from: "manual-edit",
- history: [...video.guess.history, video.guess],
- },
- for: video.guess.episodes.map((x) => ({
- serie: slug,
- season: x.season,
- episode: x.episode,
- })),
- entries: undefined,
- },
- ],
- }),
- invalidate: ["api", "series", "slug"],
- optimisticKey: VideosModal.query(slug),
- optimistic: (params, prev?: { pages: Page[] }) => ({
- ...prev!,
- pages: prev!.pages.map((p, i) => {
- const idx = p.items.findIndex(
- (x) => params.path.localeCompare(x.path) < 0,
- );
- if (idx !== -1) {
- return {
- ...p,
- items: [
- ...p.items.slice(0, idx),
- params,
- ...p.items.slice(idx, -1),
- ],
- };
- }
- if (i === prev!.pages.length) {
- return { ...p, items: [...p.items, params] };
- }
- return p;
- }),
- }),
- });
return (
+ {[...titles].map((title) => (
+
+ {t("show.videos-map-related", { title })}
+ {
+ setTitles(titles.filter((x) => x !== title));
+ }}
+ {...tooltip(t("misc.cancel"))}
+ />
+
+ ))}
(
-
- {item.path}
-
+ Render={({ item }) => {
+ 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 (
+
+
+ {saved ? (
+ {
+ if (!titles.includes(item.guess.title))
+ setTitles([...titles, item.guess.title]);
+ await editLinks({ video: item.id, entries: [] });
+ }}
+ {...tooltip(t("show.videos-map-delete"))}
+ />
+ ) : guess.length ? (
+ {
+ await editLinks({
+ video: item.id,
+ entries: guess,
+ guess: true,
+ });
+ }}
+ {...tooltip(t("show.videos-map-validate"))}
+ />
+ ) : (
+
+ )}
+ {item.path}
+
({
parser: Entry,
path: ["api", "series", slug, "entries"],
@@ -114,20 +161,28 @@ export const VideosModal = () => {
},
infinite: true,
})}
- getKey={(x) => x.id}
+ 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 && !titles.includes(item.guess.title))
+ setTitles([...titles, item.guess.title]);
await editLinks({
video: item.id,
entries,
});
}}
/>
- {}} />
-
- )}
+ );
+ }}
Loader={() => }
Footer={
{
@@ -153,7 +208,10 @@ export const VideosModal = () => {
})}
getKey={(x) => x.id}
getLabel={(x) => x.path}
- onValueChange={async (x) => await editGuess(x!)}
+ onValueChange={(x) => {
+ if (x && !titles.includes(x.guess.title))
+ setTitles([...titles, x.guess.title]);
+ }}
/>
}
/>
@@ -161,11 +219,15 @@ export const VideosModal = () => {
);
};
-VideosModal.query = (slug: string): QueryIdentifier => ({
+VideosModal.query = (
+ slug: string,
+ titles: string[] | null,
+): QueryIdentifier => ({
parser: FullVideo,
path: ["api", "series", slug, "videos"],
params: {
sort: "path",
+ titles: titles ?? undefined,
},
infinite: true,
});
diff --git a/front/src/ui/details/season.tsx b/front/src/ui/details/season.tsx
index 87aa301b..2f822352 100644
--- a/front/src/ui/details/season.tsx
+++ b/front/src/ui/details/season.tsx
@@ -149,7 +149,6 @@ export const EntryList = ({
{index === 0 ? : }
)}
- margin={false}
{...props}
/>
);
diff --git a/front/src/utils.ts b/front/src/utils.ts
index 5708ac43..46a6e27d 100644
--- a/front/src/utils.ts
+++ b/front/src/utils.ts
@@ -75,3 +75,13 @@ export function shuffle(array: T[]): T[] {
return array;
}
+
+export function uniqBy(a: T[], key: (val: T) => string | number): T[] {
+ const seen: Record = {};
+ return a.filter((item) => {
+ const k = key(item);
+ if (seen[k]) return false;
+ seen[k] = true;
+ return true;
+ });
+}
From 58deb07b9c50a593d1edfe4edf558e4cf576acd6 Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Wed, 11 Mar 2026 12:39:51 +0100
Subject: [PATCH 11/12] Split videos-modal code
---
api/src/controllers/videos.ts | 7 +-
biome.json | 15 +-
front/public/translations/en.json | 18 +-
front/src/primitives/button.tsx | 6 +-
front/src/ui/admin/videos-modal.tsx | 233 ------------------
front/src/ui/admin/videos-modal/headers.tsx | 92 +++++++
front/src/ui/admin/videos-modal/index.tsx | 130 ++++++++++
front/src/ui/admin/videos-modal/mutate.ts | 1 +
front/src/ui/admin/videos-modal/path-item.tsx | 109 ++++++++
9 files changed, 351 insertions(+), 260 deletions(-)
delete mode 100644 front/src/ui/admin/videos-modal.tsx
create mode 100644 front/src/ui/admin/videos-modal/headers.tsx
create mode 100644 front/src/ui/admin/videos-modal/index.tsx
create mode 100644 front/src/ui/admin/videos-modal/mutate.ts
create mode 100644 front/src/ui/admin/videos-modal/path-item.tsx
diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts
index ddfdd073..e6244159 100644
--- a/api/src/controllers/videos.ts
+++ b/api/src/controllers/videos.ts
@@ -3,6 +3,7 @@ import {
desc,
eq,
gt,
+ inArray,
isNotNull,
lt,
max,
@@ -11,8 +12,7 @@ import {
or,
type SQL,
sql,
- inArray,
- WithSubquery,
+ type WithSubquery,
} from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { Elysia, t } from "elysia";
@@ -33,7 +33,6 @@ import {
jsonbBuildObject,
jsonbObjectAgg,
sqlarr,
- unnest,
} from "~/db/utils";
import type { Entry } from "~/models/entry";
import { KError } from "~/models/error";
@@ -82,7 +81,7 @@ const videoSort = Sort(
],
},
{
- default: ["entry", "path"],
+ default: ["path"],
tablePk: videos.pk,
},
);
diff --git a/biome.json b/biome.json
index 79b45ac7..5123d159 100644
--- a/biome.json
+++ b/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.*"]
}
}
}
diff --git a/front/public/translations/en.json b/front/public/translations/en.json
index c7424a2f..e8dac9c8 100644
--- a/front/public/translations/en.json
+++ b/front/public/translations/en.json
@@ -45,13 +45,17 @@
"season": "Season {{number}}",
"multiVideos": "Multiples video files available",
"videosCount": "{{number}} videos",
- "videos-map": "Edit video mappings",
- "videos-map-none": "NONE",
- "videos-map-add": "Add another video",
- "videos-map-delete": "Remove video from serie",
- "videos-map-validate": "Validate guess and add video to serie",
- "videos-map-no-guess": "This video was guessed to be part of this serie but we don't know which episode",
- "videos-map-related": "Added videos related to the title {{title}}"
+ "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": {
diff --git a/front/src/primitives/button.tsx b/front/src/primitives/button.tsx
index d380e8b4..c90c6bf2 100644
--- a/front/src/primitives/button.tsx
+++ b/front/src/primitives/button.tsx
@@ -1,9 +1,5 @@
import type { ComponentProps, ComponentType, Ref } from "react";
-import {
- type Falsy,
- 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";
diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx
deleted file mode 100644
index 767f60d4..00000000
--- a/front/src/ui/admin/videos-modal.tsx
+++ /dev/null
@@ -1,233 +0,0 @@
-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 LibraryAdd from "@material-symbols/svg-400/rounded/library_add-fill.svg";
-import { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { View } from "react-native";
-import { entryDisplayNumber } from "~/components/entries";
-import { Entry, FullVideo, type Page } from "~/models";
-import {
- Button,
- ComboBox,
- 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 { uniqBy } from "~/utils";
-
-export const VideosModal = () => {
- const [slug] = useQueryState("slug", undefined!);
- const { data } = useFetch(Header.query("serie", slug));
- const { t } = useTranslation();
- const [titles, setTitles] = useState([]);
-
- const { mutateAsync: editLinks } = useMutation({
- method: "PUT",
- path: ["api", "videos", "link"],
- compute: ({
- video,
- entries,
- guess = false,
- }: {
- video: string;
- entries: Omit[];
- 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, null),
- optimistic: (params, prev?: { pages: Page[] }) => ({
- ...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 (
-
- {[...titles].map((title) => (
-
- {t("show.videos-map-related", { title })}
- {
- setTitles(titles.filter((x) => x !== title));
- }}
- {...tooltip(t("misc.cancel"))}
- />
-
- ))}
- {
- 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 (
-
-
- {saved ? (
- {
- if (!titles.includes(item.guess.title))
- setTitles([...titles, item.guess.title]);
- await editLinks({ video: item.id, entries: [] });
- }}
- {...tooltip(t("show.videos-map-delete"))}
- />
- ) : guess.length ? (
- {
- await editLinks({
- video: item.id,
- entries: guess,
- guess: true,
- });
- }}
- {...tooltip(t("show.videos-map-validate"))}
- />
- ) : (
-
- )}
- {item.path}
-
- ({
- parser: Entry,
- path: ["api", "series", slug, "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 && !titles.includes(item.guess.title))
- setTitles([...titles, item.guess.title]);
- await editLinks({
- video: item.id,
- entries,
- });
- }}
- />
-
- );
- }}
- Loader={() => }
- Footer={
- (
-
- )}
- 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 && !titles.includes(x.guess.title))
- setTitles([...titles, x.guess.title]);
- }}
- />
- }
- />
-
- );
-};
-
-VideosModal.query = (
- slug: string,
- titles: string[] | null,
-): QueryIdentifier => ({
- parser: FullVideo,
- path: ["api", "series", slug, "videos"],
- params: {
- sort: "path",
- titles: titles ?? undefined,
- },
- infinite: true,
-});
diff --git a/front/src/ui/admin/videos-modal/headers.tsx b/front/src/ui/admin/videos-modal/headers.tsx
new file mode 100644
index 00000000..18a57a22
--- /dev/null
+++ b/front/src/ui/admin/videos-modal/headers.tsx
@@ -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 (
+
+ );
+};
+
+export const AddVideoFooter = ({
+ addTitle,
+}: {
+ addTitle: (title: string) => void;
+}) => {
+ const { t } = useTranslation();
+
+ return (
+ (
+
+ )}
+ 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);
+ }}
+ />
+ );
+};
diff --git a/front/src/ui/admin/videos-modal/index.tsx b/front/src/ui/admin/videos-modal/index.tsx
new file mode 100644
index 00000000..13250018
--- /dev/null
+++ b/front/src/ui/admin/videos-modal/index.tsx
@@ -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[];
+ 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[] }) => ({
+ ...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("slug", undefined!);
+ const { data } = useFetch(Header.query("serie", slug));
+ const { t } = useTranslation();
+ const [titles, setTitles] = useState([]);
+ 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 (
+
+ {[...titles].map((title) => (
+
+ {t("videos-map.related", { title })}
+ {
+ setTitles(titles.filter((x) => x !== title));
+ }}
+ {...tooltip(t("misc.cancel"))}
+ />
+
+ ))}
+
+
+
+ (
+
+ )}
+ Loader={() => }
+ Footer={}
+ />
+
+ );
+};
+
+VideosModal.query = (
+ slug: string,
+ titles: string[],
+ sort: "path" | "entry",
+): QueryIdentifier => ({
+ parser: FullVideo,
+ path: ["api", "series", slug, "videos"],
+ params: {
+ sort: sort === "entry" ? ["entry", "path"] : sort,
+ titles: titles,
+ },
+ infinite: true,
+});
diff --git a/front/src/ui/admin/videos-modal/mutate.ts b/front/src/ui/admin/videos-modal/mutate.ts
new file mode 100644
index 00000000..cb0ff5c3
--- /dev/null
+++ b/front/src/ui/admin/videos-modal/mutate.ts
@@ -0,0 +1 @@
+export {};
diff --git a/front/src/ui/admin/videos-modal/path-item.tsx b/front/src/ui/admin/videos-modal/path-item.tsx
new file mode 100644
index 00000000..ce3bcf24
--- /dev/null
+++ b/front/src/ui/admin/videos-modal/path-item.tsx
@@ -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;
+}) => {
+ 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 (
+
+
+ {saved ? (
+ {
+ addTitle(item.guess.title);
+ await editLinks({ video: item.id, entries: [] });
+ }}
+ {...tooltip(t("videos-map.delete"))}
+ />
+ ) : guess.length ? (
+ {
+ await editLinks({
+ video: item.id,
+ entries: guess,
+ guess: true,
+ });
+ }}
+ {...tooltip(t("videos-map.validate"))}
+ />
+ ) : (
+
+ )}
+ {item.path}
+
+ ({
+ 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,
+ });
+ }}
+ />
+
+ );
+};
From 11167765e72b212de30a6745f38d3297e077453f Mon Sep 17 00:00:00 2001
From: Zoe Roux
Date: Wed, 11 Mar 2026 12:56:22 +0100
Subject: [PATCH 12/12] Add back `slugs` field in videos
---
api/src/controllers/videos.ts | 16 +++++++++++++++-
api/src/models/full-video.ts | 3 +++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts
index e6244159..80c60650 100644
--- a/api/src/controllers/videos.ts
+++ b/api/src/controllers/videos.ts
@@ -29,7 +29,9 @@ import {
} from "~/db/schema";
import { watchlist } from "~/db/schema/watchlist";
import {
+ coalesce,
getColumns,
+ jsonbAgg,
jsonbBuildObject,
jsonbObjectAgg,
sqlarr,
@@ -87,6 +89,18 @@ const videoSort = Sort(
);
const videoRelations = {
+ slugs: () => {
+ return db
+ .select({
+ slugs: coalesce(
+ jsonbAgg(entryVideoJoin.slug),
+ sql`'[]'::jsonb`,
+ ).as("slugs"),
+ })
+ .from(entryVideoJoin)
+ .where(eq(entryVideoJoin.videoPk, videos.pk))
+ .as("slugs");
+ },
progress: () => {
const query = db
.select({
@@ -294,7 +308,7 @@ export async function getVideos({
.with(...cte)
.select({
...getColumns(videos),
- ...buildRelations(relations, videoRelations, {
+ ...buildRelations(["slugs", ...relations], videoRelations, {
languages,
preferOriginal,
}),
diff --git a/api/src/models/full-video.ts b/api/src/models/full-video.ts
index cef94374..06ed7ce9 100644
--- a/api/src/models/full-video.ts
+++ b/api/src/models/full-video.ts
@@ -8,6 +8,9 @@ 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(