import { and, desc, eq, exists, gt, inArray, isNotNull, lt, max, min, notExists, or, type SQL, sql, type WithSubquery, } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { Elysia, t } from "elysia"; import { auth } from "~/auth"; import { db } from "~/db"; import { entries, entryVideoJoin, history, profiles, shows, showTranslations, videos, } from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; import { coalesce, getColumns, jsonbAgg, jsonbBuildObject, jsonbObjectAgg, sqlarr, } from "~/db/utils"; import type { Entry } from "~/models/entry"; import { KError } from "~/models/error"; 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, createPage, type Image, isUuid, keysetPaginate, Page, processLanguages, type Resource, Sort, sortToSql, } from "~/models/utils"; import { desc as description } from "~/models/utils/descriptions"; import { Guesses, Video } from "~/models/video"; import type { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist"; import { comment } from "~/utils"; import { entryProgressQ, entryVideosQ, getEntryTransQ, mapProgress, } from "./entries"; const videoSort = Sort( { path: videos.path, entry: [ { sql: db .select({ showPk: entries.showPk }) .from(entryVideoJoin) .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) .where(eq(entryVideoJoin.videoPk, videos.pk)) .orderBy(entries.showPk, entries.order) .limit(1), isNullable: true, accessor: (x: any) => x.entries?.[0]?.showPk, }, { sql: db .select({ order: entries.order }) .from(entryVideoJoin) .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) .where(eq(entryVideoJoin.videoPk, videos.pk)) .orderBy(entries.showPk, entries.order) .limit(1), isNullable: true, accessor: (x: any) => x.entries?.[0]?.order, }, ], }, { default: ["path"], tablePk: videos.pk, }, ); const videoRelations = { slugs: () => { return db .select({ slugs: coalesce( jsonbAgg(entryVideoJoin.slug), sql`'[]'::jsonb`, ).as("slugs"), }) .from(entryVideoJoin) .where(eq(entryVideoJoin.videoPk, videos.pk)) .as("slugs"); }, progress: () => { const query = db .select({ json: jsonbBuildObject({ percent: history.percent, time: history.time, playedDate: history.playedDate, videoId: videos.id, }), }) .from(history) .innerJoin(profiles, eq(history.profilePk, profiles.pk)) .where( and( eq(profiles.id, sql.placeholder("userId")), eq(history.videoPk, videos.pk), ), ) .orderBy(desc(history.playedDate)) .limit(1); return sql` ( select coalesce( ${query}, '{"percent": 0, "time": 0, "playedDate": null, "videoId": null}'::jsonb ) as "progress" )` as any; }, show: ({ languages, preferOriginal, }: { languages: string[]; preferOriginal: boolean; }) => { const transQ = db .selectDistinctOn([showTranslations.pk]) .from(showTranslations) .orderBy( showTranslations.pk, sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, ) .as("t"); const watchStatusQ = db .select({ watchStatus: jsonbBuildObject({ ...getColumns(watchlist), percent: watchlist.seenCount, }).as("watchStatus"), }) .from(watchlist) .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) .where( and( eq(profiles.id, sql.placeholder("userId")), eq(watchlist.showPk, shows.pk), ), ); return db .select({ json: jsonbBuildObject({ ...getColumns(shows), ...getColumns(transQ), // movie columns (status is only a typescript hint) status: sql`${shows.status}`, airDate: shows.startAir, kind: sql`${shows.kind}`, isAvailable: sql`${shows.availableCount} != 0`, ...(preferOriginal && { poster: sql`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`, thumbnail: sql`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`, banner: sql`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`, logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, }), watchStatus: sql`${watchStatusQ}`, }).as("json"), }) .from(shows) .innerJoin(transQ, eq(shows.pk, transQ.pk)) .where( eq( shows.pk, db .select({ pk: entries.showPk }) .from(entries) .innerJoin(entryVideoJoin, eq(entryVideoJoin.entryPk, entries.pk)) .where(eq(videos.pk, entryVideoJoin.videoPk)), ), ) .limit(1) .as("show"); }, previous: ({ languages }: { languages: string[] }) => { return getNextVideoEntry({ languages, prev: true }); }, next: getNextVideoEntry, }; function getNextVideoEntry({ languages, prev = false, }: { languages: string[]; prev?: boolean; }) { const transQ = getEntryTransQ(languages); // tables we use two times in the query bellow const vids = alias(videos, `vid_${prev ? "prev" : "next"}`); const entr = alias(entries, `entr_${prev ? "prev" : "next"}`); const evj = alias(entryVideoJoin, `evj_${prev ? "prev" : "next"}`); return db .select({ json: jsonbBuildObject<{ video: string; entry: Entry }>({ video: entryVideoJoin.slug, entry: { ...getColumns(entries), ...getColumns(transQ), number: entries.episodeNumber, videos: entryVideosQ.videos, progress: mapProgress({ aliased: false }), }, }).as("json"), }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .crossJoinLateral(entryVideosQ) .leftJoin(entryVideoJoin, eq(entries.pk, entryVideoJoin.entryPk)) .innerJoin(vids, eq(vids.pk, entryVideoJoin.videoPk)) .where( and( // either way it needs to be of the same show eq( entries.showPk, db .select({ showPk: entr.showPk }) .from(entr) .innerJoin(evj, eq(evj.entryPk, entr.pk)) .where(eq(evj.videoPk, videos.pk)) .limit(1), ), or( // either the next entry (prev ? lt : gt)( entries.order, db .select({ order: (prev ? min : max)(entr.order) }) .from(entr) .innerJoin(evj, eq(evj.entryPk, entr.pk)) .where(eq(evj.videoPk, videos.pk)), ), // or the second part of the current entry and( isNotNull(videos.part), eq(vids.rendering, videos.rendering), eq(vids.part, sql`${videos.part} ${sql.raw(prev ? "-" : "+")} 1`), ), ), ), ) .orderBy( prev ? desc(entries.order) : entries.order, // prefer next part of the current entry over next entry eq(vids.rendering, videos.rendering), // take the first part available vids.part, // always prefer latest version of video desc(vids.version), ) .limit(1) .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, cte = [], }: { after?: string; limit: number; query?: string; sort?: Sort; filter?: SQL; languages: string[]; preferOriginal?: boolean; relations?: (keyof typeof videoRelations)[]; userId: string; cte?: WithSubquery[]; }) { const ret = await db .with(...cte) .select({ ...getColumns(videos), ...buildRelations(["slugs", "progress", ...relations], videoRelations, { languages, preferOriginal, }), }) .from(videos) .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 }); 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({ tags: ["videos"] }) .use(auth) .get( "videos/:id", async ({ params: { id }, query: { with: relations, preferOriginal }, headers: { "accept-language": langs }, jwt: { sub, settings }, status, }) => { const languages = processLanguages(langs); const [ret] = await getVideos({ limit: 1, filter: isUuid(id) ? eq(videos.id, id) : exists( db .select() .from(entryVideoJoin) .where( and( eq(entryVideoJoin.videoPk, videos.pk), eq(entryVideoJoin.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 ret; }, { detail: { description: "Get a video & it's related entries", }, params: t.Object({ id: t.String({ description: "The id or slug of the video to retrieve.", example: "made-in-abyss-s1e13", }), }), query: t.Object({ with: t.Array(t.UnionEnum(["previous", "next", "show"]), { default: [], description: "Include related entries in the response.", }), preferOriginal: t.Optional( t.Boolean({ description: description.preferOriginal, }), ), }), headers: t.Object({ "accept-language": AcceptLanguage(), }), response: { 200: FullVideo, 404: { ...KError, description: "No video found with the given id or slug.", }, 422: KError, }, }, ) .get( "videos", async ({ query: { limit, after, query, sort, 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, 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, }), ), }), headers: t.Object({ "accept-language": AcceptLanguage(), }), response: { 200: Page(FullVideo), 404: { ...KError, description: "No video found with the given id or slug.", }, 422: KError, }, }, ) .get( "videos/guesses", async () => { const years = db.$with("years").as( db .select({ guess: sql`${videos.guess}->>'title'`.as("guess"), year: sql`coalesce(year, 'unknown')`.as("year"), id: shows.id, slug: shows.slug, }) .from(videos) .leftJoin( sql`jsonb_array_elements_text(${videos.guess}->'years') as year`, sql`true`, ) .innerJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) .innerJoin(shows, eq(shows.pk, entries.showPk)), ); const guess = db.$with("guess").as( db .select({ guess: years.guess, years: jsonbObjectAgg( years.year, jsonbBuildObject({ id: years.id, slug: years.slug }), ).as("years"), }) .from(years) .groupBy(years.guess), ); const [{ guesses }] = await db .with(years, guess) .select({ guesses: jsonbObjectAgg>( guess.guess, guess.years, ), }) .from(guess); const paths = await db.select({ path: videos.path }).from(videos); const unmatched = await db .select({ path: videos.path }) .from(videos) .where( notExists( db .select() .from(entryVideoJoin) .where(eq(entryVideoJoin.videoPk, videos.pk)), ), ); return { paths: paths.map((x) => x.path), guesses: guesses ?? {}, unmatched: unmatched.map((x) => x.path), }; }, { detail: { description: "Get all video registered & guessed made" }, response: { 200: Guesses, }, }, ) .get( "videos/unmatched", async ({ query: { sort, query, limit, after }, headers, request: { url }, }) => { const ret = await db .select() .from(videos) .where( and( notExists( db .select() .from(entryVideoJoin) .where(eq(videos.pk, entryVideoJoin.videoPk)), ), query ? or( sql`${videos.path} %> ${query}::text`, sql`${videos.guess}->>'title' %> ${query}::text`, ) : undefined, keysetPaginate({ after, sort }), ), ) .orderBy(...(query ? [] : sortToSql(sort)), videos.pk) .limit(limit); return createPage(ret, { url, sort, limit, headers }); }, { detail: { description: "Get unknown/unmatched videos." }, query: t.Object({ sort: Sort( { createdAt: videos.createdAt, path: videos.path }, { default: ["-createdAt"], tablePk: videos.pk }, ), 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 })), }), response: { 200: Page(Video), 422: KError, }, }, ) .get( "series/:id/videos", async ({ params: { id }, query: { limit, after, query, sort, preferOriginal, titles }, 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 titleGuess = db.$with("title_guess").as( db .selectDistinctOn([sql`${videos.guess}->>'title'`], { title: sql`${videos.guess}->>'title'`.as("title"), }) .from(videos) .leftJoin(evJoin, eq(videos.pk, evJoin.videoPk)) .leftJoin(entries, eq(entries.pk, evJoin.entryPk)) .where(eq(entries.showPk, serie.pk)) .union( db .select({ title: sql`title` }) .from(sql`unnest(${sqlarr(titles ?? [])}::text[]) as title`), ), ); const languages = processLanguages(langs); const items = await getVideos({ cte: [titleGuess], filter: or( exists( db .select() .from(entryVideoJoin) .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) .where( and( eq(entryVideoJoin.videoPk, videos.pk), eq(entries.showPk, serie.pk), ), ), ), inArray( sql`${videos.guess}->>'title'`, db.select().from(titleGuess), ), ), limit, after, query, sort, languages, preferOriginal: preferOriginal ?? settings.preferOriginal, userId: sub, }); for (const i of items) i.entries = i.entries.filter( (x) => (x as unknown as typeof entries.$inferSelect).showPk === serie.pk, ); return createPage(items, { url, sort, limit, headers }); }, { 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, }), ), titles: t.Optional( t.Array( t.String({ description: comment` Return videos in the serie + videos with a title guess equal to one of the element of this list `, }), ), ), }), headers: t.Object({ "accept-language": AcceptLanguage(), }), response: { 200: Page(FullVideo), 404: { ...KError, description: "No video found with the given id or slug.", }, 422: KError, }, }, );