diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts deleted file mode 100644 index 86eb0b52..00000000 --- a/api/src/controllers/movies.ts +++ /dev/null @@ -1,340 +0,0 @@ -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 { KError } from "~/models/error"; -import { bubble } from "~/models/examples"; -import { - FullMovie, - Movie, - 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, - }, -}; - -export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) - .model({ - movie: Movie, - "movie-translation": MovieTranslation, - }) - .get( - "/:id", - async ({ - params: { id }, - headers: { "accept-language": languages }, - query: { preferOriginal, with: relations }, - error, - set, - }) => { - const langs = processLanguages(languages); - - const ret = await db.query.shows.findFirst({ - columns: { - kind: false, - startAir: false, - endAir: false, - }, - extras: { - airDate: sql`${shows.startAir}`.as("airDate"), - status: sql`${shows.status}`.as("status"), - isAvailable: exists( - db - .select() - .from(entries) - .where( - and( - eq(shows.pk, entries.showPk), - exists( - db - .select() - .from(entryVideoJoin) - .where(eq(entries.pk, entryVideoJoin.entry)), - ), - ), - ), - ).as("isAvailable") as SQL.Aliased, - }, - where: and( - eq(shows.kind, "movie"), - isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), - ), - with: { - selectedTranslation: { - columns: { - pk: false, - }, - where: !langs.includes("*") - ? eq(showTranslations.language, sql`any(${sqlarr(langs)})`) - : undefined, - orderBy: [ - sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`, - ], - limit: 1, - }, - originalTranslation: { - columns: { - poster: true, - thumbnail: true, - banner: true, - logo: true, - }, - extras: { - // TODO: also fallback on user settings (that's why i made a select here) - preferOriginal: - sql`(select coalesce(${preferOriginal ?? null}::boolean, false))`.as( - "preferOriginal", - ), - }, - }, - ...(relations.includes("translations") && { - translations: { - columns: { - pk: false, - }, - }, - }), - }, - }); - - if (!ret) { - return error(404, { - status: 404, - message: "Movie not found", - }); - } - const translation = ret.selectedTranslation[0]; - if (!translation) { - return error(422, { - status: 422, - message: "Accept-Language header could not be satisfied.", - }); - } - set.headers["content-language"] = translation.language; - const ot = ret.originalTranslation; - return { - ...ret, - ...translation, - ...(ot?.preferOriginal && { - ...(ot.poster && { poster: ot.poster }), - ...(ot.thumbnail && { thumbnail: ot.thumbnail }), - ...(ot.banner && { banner: ot.banner }), - ...(ot.logo && { logo: ot.logo }), - }), - ...(ret.translations && { - translations: Object.fromEntries( - ret.translations.map( - ({ language, ...translation }) => - [language, translation] as const, - ), - ), - }), - }; - }, - { - detail: { - description: "Get a movie by id or slug", - }, - params: t.Object({ - id: t.String({ - description: "The id or slug of the movie to retrieve.", - example: bubble.slug, - }), - }), - query: t.Object({ - preferOriginal: t.Optional( - t.Boolean({ description: desc.preferOriginal }), - ), - with: t.Array(t.UnionEnum(["translations", "videos"]), { - default: [], - description: "Include related resources in the response.", - }), - }), - headers: t.Object({ - "accept-language": AcceptLanguage(), - }), - response: { - 200: { ...FullMovie, description: "Found" }, - 404: { - ...KError, - description: "No movie found with the given id or slug.", - }, - 422: KError, - }, - }, - ) - .get( - "random", - async ({ error, redirect }) => { - const [movie] = await db - .select({ id: shows.id }) - .from(shows) - .where(eq(shows.kind, "movie")) - .orderBy(sql`random()`) - .limit(1); - if (!movie) - return error(404, { - status: 404, - message: "No movies in the database", - }); - return redirect(`/movies/${movie.id}`); - }, - { - detail: { - description: "Get a random movie", - }, - response: { - 302: t.Void({ - description: - "Redirected to the [/movies/{id}](#tag/movies/GET/movies/{id}) route.", - }), - 404: { - ...KError, - description: "No movie found with the given id or slug.", - }, - }, - }, - ) - .get( - "", - async ({ - query: { limit, after, query, sort, filter, preferOriginal }, - headers: { "accept-language": languages }, - 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); - - 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 })), - 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(Movie), - 422: KError, - }, - }, - ); diff --git a/api/src/controllers/series.ts b/api/src/controllers/series.ts index 2879abad..6c6f65b6 100644 --- a/api/src/controllers/series.ts +++ b/api/src/controllers/series.ts @@ -1,5 +1,5 @@ import { Elysia, t } from "elysia"; -import { Serie } from "../models/serie"; +import { Serie } from "~/models/serie"; export const series = new Elysia({ prefix: "/series" }) .model({ diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts new file mode 100644 index 00000000..f378158b --- /dev/null +++ b/api/src/controllers/shows/collections.ts @@ -0,0 +1,335 @@ +import { and, eq, sql } from "drizzle-orm"; +import { Elysia, t } from "elysia"; +import { db } from "~/db"; +import { shows } from "~/db/schema"; +import { + Collection, + CollectionTranslation, + FullCollection, +} from "~/models/collections"; +import { KError } from "~/models/error"; +import { duneCollection } from "~/models/examples"; +import { Movie } from "~/models/movie"; +import { + AcceptLanguage, + Filter, + Page, + createPage, + isUuid, + processLanguages, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { getShow, getShows, showFilters, showSort } from "./logic"; + +export const collections = new Elysia({ + prefix: "/collections", + tags: ["collections"], +}) + .model({ + collection: Collection, + "collection-translation": CollectionTranslation, + }) + .get( + "/:id", + async ({ + params: { id }, + headers: { "accept-language": languages }, + query: { preferOriginal, with: relations }, + error, + set, + }) => { + const langs = processLanguages(languages); + const ret = await getShow(id, { + languages: langs, + preferOriginal, + relations, + filters: eq(shows.kind, "collection"), + }); + if (!ret) { + return error(404, { + status: 404, + message: "Collection not found", + }); + } + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + }); + } + set.headers["content-language"] = ret.language; + return ret.show; + }, + { + detail: { + description: "Get a collection by id or slug", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the collection to retrieve.", + example: duneCollection.slug, + }), + }), + query: t.Object({ + preferOriginal: t.Optional( + t.Boolean({ description: desc.preferOriginal }), + ), + with: t.Array(t.UnionEnum(["translations"]), { + default: [], + description: "Include related resources in the response.", + }), + }), + headers: t.Object({ + "accept-language": AcceptLanguage(), + }), + response: { + 200: { ...FullCollection, description: "Found" }, + 404: { + ...KError, + description: "No collection found with the given id or slug.", + }, + 422: KError, + }, + }, + ) + .get( + "random", + async ({ error, redirect }) => { + const [serie] = await db + .select({ id: shows.id }) + .from(shows) + .where(eq(shows.kind, "collection")) + .orderBy(sql`random()`) + .limit(1); + if (!serie) + return error(404, { + status: 404, + message: "No collection in the database.", + }); + return redirect(`/collections/${serie.id}`); + }, + { + detail: { + description: "Get a random collection", + }, + response: { + 302: t.Void({ + description: + "Redirected to the [/collections/{id}](#tag/collections/GET/collections/{id}) route.", + }), + 404: { + ...KError, + description: "No collections in the database.", + }, + }, + }, + ) + .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, "collection"), filter), + languages: langs, + preferOriginal, + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all collections" }, + 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(Collection), + 422: KError, + }, + }, + ) + .get( + "/:id/movies", + async ({ + params: { id }, + query: { limit, after, query, sort, filter, preferOriginal }, + headers: { "accept-language": languages }, + request: { url }, + error, + }) => { + const [collection] = await db + .select({ pk: shows.pk }) + .from(shows) + .where( + and( + eq(shows.kind, "collection"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + ), + ) + .limit(1); + + if (!collection) { + return error(404, { + status: 404, + message: `No collection with the id or slug: '${id}'.`, + }); + } + + const langs = processLanguages(languages); + const items = await getShows({ + limit, + after, + query, + sort, + filter: and( + eq(shows.collectionPk, collection.pk), + eq(shows.kind, "movie"), + filter, + ), + languages: langs, + preferOriginal, + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all movies in a collection" }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the collection.", + example: duneCollection.slug, + }), + }), + 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(Movie), + 404: { + ...KError, + description: "No collection found with the given id or slug.", + }, + 422: KError, + }, + }, + ) + .get( + "/:id/series", + async ({ + params: { id }, + query: { limit, after, query, sort, filter, preferOriginal }, + headers: { "accept-language": languages }, + request: { url }, + error, + }) => { + const [collection] = await db + .select({ pk: shows.pk }) + .from(shows) + .where( + and( + eq(shows.kind, "collection"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + ), + ) + .limit(1); + + if (!collection) { + return error(404, { + status: 404, + message: `No collection with the id or slug: '${id}'.`, + }); + } + + const langs = processLanguages(languages); + const items = await getShows({ + limit, + after, + query, + sort, + filter: and( + eq(shows.collectionPk, collection.pk), + eq(shows.kind, "serie"), + filter, + ), + languages: langs, + preferOriginal, + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all series in a collection" }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the collection.", + example: duneCollection.slug, + }), + }), + 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(Movie), + 404: { + ...KError, + description: "No collection found with the given id or slug.", + }, + 422: KError, + }, + }, + ); diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts new file mode 100644 index 00000000..b13bd51c --- /dev/null +++ b/api/src/controllers/shows/logic.ts @@ -0,0 +1,201 @@ +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, + isUuid, + 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); +} + +export async function getShow( + id: string, + { + languages, + preferOriginal, + relations, + filters, + }: { + languages: string[]; + preferOriginal: boolean | undefined; + relations: ("translations" | "videos")[]; + filters: SQL | undefined; + }, +) { + const ret = await db.query.shows.findFirst({ + extras: { + airDate: sql`${shows.startAir}`.as("airDate"), + status: sql`${shows.status}`.as("status"), + }, + where: and(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), filters), + with: { + selectedTranslation: { + columns: { + pk: false, + }, + where: !languages.includes("*") + ? eq(showTranslations.language, sql`any(${sqlarr(languages)})`) + : undefined, + orderBy: [ + sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, + ], + limit: 1, + }, + originalTranslation: { + columns: { + poster: true, + thumbnail: true, + banner: true, + logo: true, + }, + extras: { + // TODO: also fallback on user settings (that's why i made a select here) + preferOriginal: + sql`(select coalesce(${preferOriginal ?? null}::boolean, false))`.as( + "preferOriginal", + ), + }, + }, + ...(relations.includes("translations") && { + translations: { + columns: { + pk: false, + }, + }, + }), + }, + }); + if (!ret) return null; + const translation = ret.selectedTranslation[0]; + if (!translation) return { show: null, language: null }; + const ot = ret.originalTranslation; + const show = { + ...ret, + ...translation, + ...(ot?.preferOriginal && { + ...(ot.poster && { poster: ot.poster }), + ...(ot.thumbnail && { thumbnail: ot.thumbnail }), + ...(ot.banner && { banner: ot.banner }), + ...(ot.logo && { logo: ot.logo }), + }), + ...(ret.translations && { + translations: Object.fromEntries( + ret.translations.map( + ({ language, ...translation }) => [language, translation] as const, + ), + ), + }), + }; + return { show, language: translation.language }; +} diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts new file mode 100644 index 00000000..eb011711 --- /dev/null +++ b/api/src/controllers/shows/movies.ts @@ -0,0 +1,164 @@ +import { type SQL, and, eq, exists, sql } from "drizzle-orm"; +import { Elysia, t } from "elysia"; +import { db } from "~/db"; +import { shows } from "~/db/schema"; +import { KError } from "~/models/error"; +import { bubble } from "~/models/examples"; +import { FullMovie, Movie, MovieTranslation } from "~/models/movie"; +import { + AcceptLanguage, + Filter, + Page, + createPage, + processLanguages, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { getShow, getShows, showFilters, showSort } from "./logic"; + +export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) + .model({ + movie: Movie, + "movie-translation": MovieTranslation, + }) + .get( + "/:id", + async ({ + params: { id }, + headers: { "accept-language": languages }, + query: { preferOriginal, with: relations }, + error, + set, + }) => { + const langs = processLanguages(languages); + const ret = await getShow(id, { + languages: langs, + preferOriginal, + relations, + filters: eq(shows.kind, "movie"), + }); + if (!ret) { + return error(404, { + status: 404, + message: "Movie not found", + }); + } + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + }); + } + set.headers["content-language"] = ret.language; + return ret.show; + }, + { + detail: { + description: "Get a movie by id or slug", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the movie to retrieve.", + example: bubble.slug, + }), + }), + query: t.Object({ + preferOriginal: t.Optional( + t.Boolean({ description: desc.preferOriginal }), + ), + with: t.Array(t.UnionEnum(["translations", "videos"]), { + default: [], + description: "Include related resources in the response.", + }), + }), + headers: t.Object({ + "accept-language": AcceptLanguage(), + }), + response: { + 200: { ...FullMovie, description: "Found" }, + 404: { + ...KError, + description: "No movie found with the given id or slug.", + }, + 422: KError, + }, + }, + ) + .get( + "random", + async ({ error, redirect }) => { + const [movie] = await db + .select({ id: shows.id }) + .from(shows) + .where(eq(shows.kind, "movie")) + .orderBy(sql`random()`) + .limit(1); + if (!movie) + return error(404, { + status: 404, + message: "No movies in the database.", + }); + return redirect(`/movies/${movie.id}`); + }, + { + detail: { + description: "Get a random movie", + }, + response: { + 302: t.Void({ + description: + "Redirected to the [/movies/{id}](#tag/movies/GET/movies/{id}) route.", + }), + 404: { + ...KError, + description: "No movies in the database.", + }, + }, + }, + ) + .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, "movie"), filter), + languages: langs, + preferOriginal, + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all movies" }, + 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(Movie), + 422: KError, + }, + }, + ); diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts new file mode 100644 index 00000000..f903621e --- /dev/null +++ b/api/src/controllers/shows/series.ts @@ -0,0 +1,164 @@ +import { and, eq, sql } from "drizzle-orm"; +import { Elysia, t } from "elysia"; +import { db } from "~/db"; +import { shows } from "~/db/schema"; +import { KError } from "~/models/error"; +import { madeInAbyss } from "~/models/examples"; +import { FullSerie, Serie, SerieTranslation } from "~/models/serie"; +import { + AcceptLanguage, + Filter, + Page, + createPage, + processLanguages, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { getShow, getShows, showFilters, showSort } from "./logic"; + +export const series = new Elysia({ prefix: "/series", tags: ["series"] }) + .model({ + serie: Serie, + "serie-translation": SerieTranslation, + }) + .get( + "/:id", + async ({ + params: { id }, + headers: { "accept-language": languages }, + query: { preferOriginal, with: relations }, + error, + set, + }) => { + const langs = processLanguages(languages); + const ret = await getShow(id, { + languages: langs, + preferOriginal, + relations, + filters: eq(shows.kind, "serie"), + }); + if (!ret) { + return error(404, { + status: 404, + message: "Movie not found", + }); + } + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + }); + } + set.headers["content-language"] = ret.language; + return ret.show; + }, + { + detail: { + description: "Get a serie by id or slug", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the serie to retrieve.", + example: madeInAbyss.slug, + }), + }), + query: t.Object({ + preferOriginal: t.Optional( + t.Boolean({ description: desc.preferOriginal }), + ), + with: t.Array(t.UnionEnum(["translations"]), { + default: [], + description: "Include related resources in the response.", + }), + }), + headers: t.Object({ + "accept-language": AcceptLanguage(), + }), + response: { + 200: { ...FullSerie, description: "Found" }, + 404: { + ...KError, + description: "No movie found with the given id or slug.", + }, + 422: KError, + }, + }, + ) + .get( + "random", + async ({ error, redirect }) => { + const [serie] = await db + .select({ id: shows.id }) + .from(shows) + .where(eq(shows.kind, "serie")) + .orderBy(sql`random()`) + .limit(1); + if (!serie) + return error(404, { + status: 404, + message: "No series in the database.", + }); + return redirect(`/series/${serie.id}`); + }, + { + detail: { + description: "Get a random serie", + }, + response: { + 302: t.Void({ + description: + "Redirected to the [/series/{id}](#tag/series/GET/series/{id}) route.", + }), + 404: { + ...KError, + description: "No series in the database.", + }, + }, + }, + ) + .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/elysia.ts b/api/src/elysia.ts index 07152bcc..7a86800a 100644 --- a/api/src/elysia.ts +++ b/api/src/elysia.ts @@ -1,11 +1,11 @@ import { Elysia } from "elysia"; import { entriesH } from "./controllers/entries"; -import { movies } from "./controllers/movies"; import { seasonsH } from "./controllers/seasons"; import { seed } from "./controllers/seed"; -import { series } from "./controllers/series"; +import { collections } from "./controllers/shows/collections"; +import { movies } from "./controllers/shows/movies"; +import { series } from "./controllers/shows/series"; import { videosH } from "./controllers/videos"; - import type { KError } from "./models/error"; export const base = new Elysia({ name: "base" }) @@ -42,6 +42,7 @@ export const app = new Elysia() .use(base) .use(movies) .use(series) + .use(collections) .use(entriesH) .use(seasonsH) .use(videosH) diff --git a/api/src/index.ts b/api/src/index.ts index 7f2d13ab..c125adf9 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -50,6 +50,7 @@ app tags: [ { name: "movies", description: "Routes about movies" }, { name: "series", description: "Routes about series" }, + { name: "collections", description: "Routes about collections" }, { name: "videos", description: comment` diff --git a/api/src/models/collections.ts b/api/src/models/collections.ts index db393f3e..e82edce8 100644 --- a/api/src/models/collections.ts +++ b/api/src/models/collections.ts @@ -5,6 +5,7 @@ import { ExternalId, Genre, Image, + Language, Resource, SeedImage, TranslationRecord, @@ -25,6 +26,11 @@ const BaseCollection = t.Object({ descrpition: "Date of the last item of the collection", }), ), + originalLanguage: t.Nullable( + Language({ + description: "The language code this movie was made in.", + }), + ), createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), @@ -52,6 +58,14 @@ export const Collection = t.Intersect([ ]); export type Collection = Prettify; +export const FullCollection = t.Intersect([ + Collection, + t.Object({ + translations: t.Optional(TranslationRecord(CollectionTranslation)), + }), +]); +export type FullCollection = Prettify; + export const SeedCollection = t.Intersect([ t.Omit(BaseCollection, ["startAir", "endAir", "createdAt", "nextRefresh"]), t.Object({ diff --git a/api/src/models/examples/dune-collection.ts b/api/src/models/examples/dune-collection.ts index c0b636aa..4c249975 100644 --- a/api/src/models/examples/dune-collection.ts +++ b/api/src/models/examples/dune-collection.ts @@ -18,6 +18,7 @@ export const duneCollection: SeedCollection = { logo: "https://image.tmdb.org/t/p/original/5nDsd3u1c6kDphbtIqkHseLg7HL.png", }, }, + originalLanguage: "en", genres: ["adventure", "science-fiction"], rating: 80, externalId: { diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index d0c0aa7f..da62b42a 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; @@ -68,7 +68,7 @@ export const FullMovie = t.Intersect([ videos: t.Optional(t.Array(Video)), }), ]); -export type FullMovie = typeof FullMovie.static; +export type FullMovie = Prettify; export const SeedMovie = t.Intersect([ t.Omit(BaseMovie, ["createdAt", "nextRefresh"]), diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index dafeeefe..029286de 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -61,6 +61,14 @@ export type SerieTranslation = typeof SerieTranslation.static; export const Serie = t.Intersect([Resource(), SerieTranslation, BaseSerie]); export type Serie = Prettify; +export const FullSerie = t.Intersect([ + Serie, + t.Object({ + translations: t.Optional(TranslationRecord(SerieTranslation)), + }), +]); +export type FullMovie = Prettify; + export const SeedSerie = t.Intersect([ t.Omit(BaseSerie, ["createdAt", "nextRefresh"]), t.Object({