import { type SQL, and, eq, exists, ne, sql } from "drizzle-orm"; import { db } from "~/db"; import { entries, entryTranslations, entryVideoJoin, profiles, showStudioJoin, showTranslations, shows, studioTranslations, studios, 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 type { MovieStatus } from "~/models/movie"; import { SerieStatus, type SerieTranslation } from "~/models/serie"; import type { Studio } from "~/models/studio"; import { type FilterDef, Genre, type Image, Sort, buildRelations, keysetPaginate, sortToSql, } from "~/models/utils"; import type { EmbeddedVideo } from "~/models/video"; import { WatchlistStatus } from "~/models/watchlist"; import { entryVideosQ, getEntryProgressQ, mapProgress } from "../entries"; export const watchStatusQ = db .select({ ...getColumns(watchlist), percent: sql`${watchlist.seenCount}`.as("percent"), }) .from(watchlist) .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) .where(eq(profiles.id, sql.placeholder("userId"))) .as("watchstatus"); export const showFilters: FilterDef = { genres: { column: shows.genres, type: "enum", values: Genre.enum, isArray: true, }, rating: { column: shows.rating, type: "int" }, status: { column: shows.status, type: "enum", values: SerieStatus.enum }, runtime: { column: shows.runtime, type: "float" }, airDate: { column: shows.startAir, type: "date" }, startAir: { column: shows.startAir, type: "date" }, endAir: { column: shows.startAir, type: "date" }, originalLanguage: { column: sql`${shows.original}->'language'`, type: "string", }, tags: { column: sql.raw(`t.${showTranslations.tags.name}`), type: "string", isArray: true, }, watchStatus: { column: watchStatusQ.status, type: "enum", values: WatchlistStatus.enum, }, }; export const showSort = Sort( { slug: shows.slug, rating: shows.rating, airDate: shows.startAir, startAir: shows.startAir, endAir: shows.endAir, createdAt: shows.createdAt, nextRefresh: shows.nextRefresh, watchStatus: watchStatusQ.status, }, { default: ["slug"], tablePk: shows.pk, }, ); const showRelations = { translations: () => { const { pk, language, ...trans } = getColumns(showTranslations); return db .select({ json: jsonbObjectAgg( language, jsonbBuildObject(trans), ).as("json"), }) .from(showTranslations) .where(eq(showTranslations.pk, shows.pk)) .as("translations"); }, studios: ({ languages }: { languages: string[] }) => { const { pk: _, ...studioCol } = getColumns(studios); const studioTransQ = db .selectDistinctOn([studioTranslations.pk]) .from(studioTranslations) .orderBy( studioTranslations.pk, sql`array_position(${sqlarr(languages)}, ${studioTranslations.language})`, ) .as("t"); const { pk, language, ...studioTrans } = getColumns(studioTransQ); return db .select({ json: coalesce( jsonbAgg(jsonbBuildObject({ ...studioTrans, ...studioCol })), sql`'[]'::jsonb`, ).as("json"), }) .from(studios) .leftJoin(studioTransQ, eq(studios.pk, studioTransQ.pk)) .where( exists( db .select() .from(showStudioJoin) .where( and( eq(showStudioJoin.studioPk, studios.pk), eq(showStudioJoin.showPk, shows.pk), ), ), ), ) .as("studios"); }, // only available for movies videos: () => { const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos); return db .select({ videos: coalesce( jsonbAgg( jsonbBuildObject({ slug: entryVideoJoin.slug, ...videosCol, }), ), sql`'[]'::jsonb`, ).as("videos"), }) .from(entryVideoJoin) .where(eq(entryVideoJoin.entryPk, entries.pk)) .leftJoin(entries, eq(entries.showPk, shows.pk)) .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); }, firstEntry: ({ languages, userId, }: { languages: string[]; userId: string }) => { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) .orderBy( entryTranslations.pk, sql`array_position(${sqlarr(languages)}, ${entryTranslations.language})`, ) .as("t"); const { pk, ...transCol } = getColumns(transQ); const progressQ = getEntryProgressQ(userId); return db .select({ firstEntry: jsonbBuildObject({ ...getColumns(entries), ...transCol, number: entries.episodeNumber, videos: entryVideosQ.videos, progress: mapProgress(progressQ), }).as("firstEntry"), }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .leftJoinLateral(entryVideosQ, sql`true`) .where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra"))) .orderBy(entries.order) .limit(1) .as("firstEntry"); }, nextEntry: ({ languages, userId, }: { languages: string[]; userId: string; }) => { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) .orderBy( entryTranslations.pk, sql`array_position(${sqlarr(languages)}, ${entryTranslations.language})`, ) .as("t"); const { pk, ...transCol } = getColumns(transQ); const progressQ = getEntryProgressQ(userId); return db .select({ nextEntry: jsonbBuildObject({ ...getColumns(entries), ...transCol, number: entries.episodeNumber, videos: entryVideosQ.videos, progress: mapProgress(progressQ), }).as("nextEntry"), }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .leftJoinLateral(entryVideosQ, sql`true`) .where(eq(watchStatusQ.nextEntry, entries.pk)) .as("nextEntry"); }, }; export async function getShows({ after, limit, query, sort, filter, languages, fallbackLanguage = true, preferOriginal = false, relations = [], userId, }: { after?: string; limit: number; query?: string; sort?: Sort; filter?: SQL; languages: string[]; fallbackLanguage?: boolean; preferOriginal?: boolean; relations?: (keyof typeof showRelations)[]; userId: string; }) { const transQ = db .selectDistinctOn([showTranslations.pk]) .from(showTranslations) .where( !fallbackLanguage ? eq(showTranslations.language, sql`any(${sqlarr(languages)})`) : undefined, ) .orderBy( showTranslations.pk, sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, ) .as("t"); return await db .select({ ...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: getColumns(watchStatusQ), ...buildRelations(relations, showRelations, { languages, userId }), }) .from(shows) .leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk)) [fallbackLanguage ? "innerJoin" : ("leftJoin" as "innerJoin")]( transQ, eq(shows.pk, transQ.pk), ) .where( and( filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, keysetPaginate({ after, sort }), ), ) .orderBy( ...(query ? [sql`word_similarity(${query}::text, ${transQ.name})`] : sortToSql(sort)), shows.pk, ) .limit(limit) .execute({ userId }); }