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}

+ 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}

-