From 1cfbe931c2febf45fcbc73573d79450d2c28434f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Feb 2026 20:35:14 +0100 Subject: [PATCH] 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())