diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 86eb0b52..2b528a78 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -2,49 +2,25 @@ import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { db } from "~/db"; import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema"; -import { getColumns, sqlarr } from "~/db/utils"; +import { sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; import { bubble } from "~/models/examples"; import { FullMovie, Movie, - MovieStatus, + type MovieStatus, MovieTranslation, } from "~/models/movie"; import { AcceptLanguage, Filter, - type FilterDef, - Genre, - type Image, Page, - Sort, createPage, isUuid, - keysetPaginate, processLanguages, - sortToSql, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; - -const movieFilters: 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: MovieStatus.enum }, - runtime: { column: shows.runtime, type: "float" }, - airDate: { column: shows.startAir, type: "date" }, - originalLanguage: { column: shows.originalLanguage, type: "string" }, - tags: { - column: sql.raw(`t.${showTranslations.tags.name}`), - type: "string", - isArray: true, - }, -}; +import { getShows, showFilters, showSort } from "./shows"; export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .model({ @@ -236,85 +212,22 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) request: { url }, }) => { const langs = processLanguages(languages); - - // we keep the pk for after handling. it will be removed by elysia's validators after. - const { kind, startAir, endAir, ...moviesCol } = getColumns(shows); - - const transQ = db - .selectDistinctOn([showTranslations.pk]) - .from(showTranslations) - .orderBy( - showTranslations.pk, - sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`, - ) - .as("t"); - const { pk, poster, thumbnail, banner, logo, ...transCol } = - getColumns(transQ); - - const videoQ = db - .select({ showPk: entries.showPk }) - .from(entries) - .where( - exists( - db - .select() - .from(entryVideoJoin) - .where(eq(entries.pk, entryVideoJoin.entry)), - ), - ) - .as("video"); - - const items = await db - .select({ - ...moviesCol, - ...transCol, - status: sql`${moviesCol.status}`, - airDate: startAir, - poster: sql`coalesce(${showTranslations.poster}, ${poster})`, - thumbnail: sql`coalesce(${showTranslations.thumbnail}, ${thumbnail})`, - banner: sql`coalesce(${showTranslations.banner}, ${banner})`, - logo: sql`coalesce(${showTranslations.logo}, ${logo})`, - isAvailable: sql`${videoQ.showPk} is not null`.as( - "isAvailable", - ), - }) - .from(shows) - .innerJoin(transQ, eq(shows.pk, transQ.pk)) - .leftJoin( - showTranslations, - and( - eq(shows.pk, showTranslations.pk), - eq(showTranslations.language, shows.originalLanguage), - // TODO: check user's settings before fallbacking to false. - sql`coalesce(${preferOriginal ?? null}::boolean, false)`, - ), - ) - .leftJoin(videoQ, eq(shows.pk, videoQ.showPk)) - .where( - and( - filter, - query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: shows, after, sort }), - ), - ) - .orderBy( - ...(query - ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, shows)), - shows.pk, - ) - .limit(limit); - + const items = await getShows({ + limit, + after, + query, + sort, + filter: and(eq(shows.kind, "movie"), filter), + languages: langs, + preferOriginal, + }); return createPage(items, { url, sort, limit }); }, { detail: { description: "Get all movies" }, query: t.Object({ - sort: Sort(["slug", "rating", "airDate", "createdAt", "nextRefresh"], { - remap: { airDate: "startAir" }, - default: ["slug"], - }), - filter: t.Optional(Filter({ def: movieFilters })), + sort: showSort, + filter: t.Optional(Filter({ def: showFilters })), query: t.Optional(t.String({ description: desc.query })), limit: t.Integer({ minimum: 1, diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 6c6f65b6..0347991c 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -1,11 +1,67 @@ +import { and, eq } from "drizzle-orm"; import { Elysia, t } from "elysia"; -import { Serie } from "~/models/serie"; +import { shows } from "~/db/schema"; +import { KError } from "~/models/error"; +import { Serie, SerieTranslation } from "~/models/serie"; +import { + AcceptLanguage, + Filter, + Page, + createPage, + processLanguages, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { getShows, showFilters, showSort } from "./shows"; -export const series = new Elysia({ prefix: "/series" }) +export const series = new Elysia({ prefix: "/series", tags: ["series"] }) .model({ serie: Serie, - error: t.Object({}), + "serie-translation": SerieTranslation, }) - .get("/:id", () => "hello" as unknown as Serie, { - response: { 200: "serie" }, - }); + .get( + "", + async ({ + query: { limit, after, query, sort, filter, preferOriginal }, + headers: { "accept-language": languages }, + request: { url }, + }) => { + const langs = processLanguages(languages); + const items = await getShows({ + limit, + after, + query, + sort, + filter: and(eq(shows.kind, "serie"), filter), + languages: langs, + preferOriginal, + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all series" }, + query: t.Object({ + sort: showSort, + filter: t.Optional(Filter({ def: showFilters })), + query: t.Optional(t.String({ description: desc.query })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional(t.String({ description: desc.after })), + preferOriginal: t.Optional( + t.Boolean({ + description: desc.preferOriginal, + }), + ), + }), + headers: t.Object({ + "accept-language": AcceptLanguage({ autoFallback: true }), + }), + response: { + 200: Page(Serie), + 422: KError, + }, + }, + ); diff --git a/api/src/controllers/shows/shows.ts b/api/src/controllers/shows/shows.ts new file mode 100644 index 00000000..44c792a7 --- /dev/null +++ b/api/src/controllers/shows/shows.ts @@ -0,0 +1,119 @@ +import type { StaticDecode } from "@sinclair/typebox"; +import { type SQL, and, eq, sql } from "drizzle-orm"; +import { db } from "~/db"; +import { showTranslations, shows } from "~/db/schema"; +import { getColumns, sqlarr } from "~/db/utils"; +import type { MovieStatus } from "~/models/movie"; +import { SerieStatus } from "~/models/serie"; +import { + type FilterDef, + Genre, + type Image, + Sort, + keysetPaginate, + sortToSql, +} from "~/models/utils"; + +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: shows.originalLanguage, type: "string" }, + tags: { + column: sql.raw(`t.${showTranslations.tags.name}`), + type: "string", + isArray: true, + }, +}; +export const showSort = Sort( + [ + "slug", + "rating", + "airDate", + "startAir", + "endAir", + "createdAt", + "nextRefresh", + ], + { + remap: { airDate: "startAir" }, + default: ["slug"], + }, +); + +export async function getShows({ + after, + limit, + query, + sort, + filter, + languages, + preferOriginal, +}: { + after: string | undefined; + limit: number; + query: string | undefined; + sort: StaticDecode; + filter: SQL | undefined; + languages: string[]; + preferOriginal: boolean | undefined; +}) { + const transQ = db + .selectDistinctOn([showTranslations.pk]) + .from(showTranslations) + .orderBy( + showTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, + ) + .as("t"); + const { pk, poster, thumbnail, banner, logo, ...transCol } = + getColumns(transQ); + + return await db + .select({ + ...getColumns(shows), + ...transCol, + // movie columns (status is only a typescript hint) + status: sql`${shows.status}`, + airDate: shows.startAir, + + poster: sql`coalesce(${showTranslations.poster}, ${poster})`, + thumbnail: sql`coalesce(${showTranslations.thumbnail}, ${thumbnail})`, + banner: sql`coalesce(${showTranslations.banner}, ${banner})`, + logo: sql`coalesce(${showTranslations.logo}, ${logo})`, + }) + .from(shows) + .innerJoin(transQ, eq(shows.pk, transQ.pk)) + .leftJoin( + showTranslations, + and( + eq(shows.pk, showTranslations.pk), + eq(showTranslations.language, shows.originalLanguage), + // TODO: check user's settings before fallbacking to false. + sql`coalesce(${preferOriginal ?? null}::boolean, false)`, + ), + ) + .where( + and( + filter, + query ? sql`${transQ.name} %> ${query}::text` : undefined, + keysetPaginate({ table: shows, after, sort }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${transQ.name})`] + : sortToSql(sort, shows)), + shows.pk, + ) + .limit(limit); +} diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index d0c0aa7f..4bed67f3 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -57,7 +57,7 @@ export const Movie = t.Intersect([ Resource(), MovieTranslation, BaseMovie, - t.Object({ isAvailable: t.Boolean() }), + // t.Object({ isAvailable: t.Boolean() }), ]); export type Movie = Prettify;