From 24f44de7c06583abe1aac7fc31c0689c282b8e88 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 1 Mar 2025 23:57:27 +0100 Subject: [PATCH 1/6] Move movie controller to shows subdir --- api/src/controllers/series.ts | 2 +- api/src/controllers/{ => shows}/movies.ts | 0 api/src/controllers/shows/series.ts | 11 +++++++++++ api/src/elysia.ts | 4 ++-- 4 files changed, 14 insertions(+), 3 deletions(-) rename api/src/controllers/{ => shows}/movies.ts (100%) create mode 100644 api/src/controllers/shows/series.ts 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/movies.ts b/api/src/controllers/shows/movies.ts similarity index 100% rename from api/src/controllers/movies.ts rename to api/src/controllers/shows/movies.ts diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts new file mode 100644 index 00000000..6c6f65b6 --- /dev/null +++ b/api/src/controllers/shows/series.ts @@ -0,0 +1,11 @@ +import { Elysia, t } from "elysia"; +import { Serie } from "~/models/serie"; + +export const series = new Elysia({ prefix: "/series" }) + .model({ + serie: Serie, + error: t.Object({}), + }) + .get("/:id", () => "hello" as unknown as Serie, { + response: { 200: "serie" }, + }); diff --git a/api/src/elysia.ts b/api/src/elysia.ts index 07152bcc..80ba8ac1 100644 --- a/api/src/elysia.ts +++ b/api/src/elysia.ts @@ -1,9 +1,9 @@ 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 { movies } from "./controllers/shows/movies"; +import { series } from "./controllers/shows/series"; import { videosH } from "./controllers/videos"; import type { KError } from "./models/error"; From cc221c560dcbf19433092170fd542a90dbd58ca0 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 2 Mar 2025 18:09:25 +0100 Subject: [PATCH 2/6] Add GET /series (using the same logic as /movies) --- api/src/controllers/shows/movies.ts | 115 ++++----------------------- api/src/controllers/shows/series.ts | 68 ++++++++++++++-- api/src/controllers/shows/shows.ts | 119 ++++++++++++++++++++++++++++ api/src/models/movie.ts | 2 +- 4 files changed, 196 insertions(+), 108 deletions(-) create mode 100644 api/src/controllers/shows/shows.ts 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; From f143511e14d4756d5b81512e31f4d367248101fa Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 2 Mar 2025 18:12:29 +0100 Subject: [PATCH 3/6] Add /series/random --- api/src/controllers/shows/movies.ts | 4 ++-- api/src/controllers/shows/series.ts | 35 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 2b528a78..4ad04fb9 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -184,7 +184,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) if (!movie) return error(404, { status: 404, - message: "No movies in the database", + message: "No movies in the database.", }); return redirect(`/movies/${movie.id}`); }, @@ -199,7 +199,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }), 404: { ...KError, - description: "No movie found with the given id or slug.", + description: "No movies in the database.", }, }, }, diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 0347991c..0d3c8c16 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -1,5 +1,6 @@ -import { and, eq } from "drizzle-orm"; +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 { Serie, SerieTranslation } from "~/models/serie"; @@ -18,6 +19,38 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) serie: Serie, "serie-translation": SerieTranslation, }) + .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 ({ From 662400da13d4ff7c443e6270626c25fe1ab77871 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 2 Mar 2025 18:53:31 +0100 Subject: [PATCH 4/6] Add GET /series/{id} (based on movie's get) --- api/src/controllers/shows/movies.ts | 111 +++------------------------- api/src/controllers/shows/series.ts | 68 ++++++++++++++++- api/src/controllers/shows/shows.ts | 82 ++++++++++++++++++++ api/src/models/movie.ts | 2 +- api/src/models/serie.ts | 8 ++ 5 files changed, 168 insertions(+), 103 deletions(-) diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 4ad04fb9..10bbbec4 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -1,26 +1,19 @@ 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 { sqlarr } from "~/db/utils"; +import { shows } from "~/db/schema"; import { KError } from "~/models/error"; import { bubble } from "~/models/examples"; -import { - FullMovie, - Movie, - type MovieStatus, - MovieTranslation, -} from "~/models/movie"; +import { FullMovie, Movie, MovieTranslation } from "~/models/movie"; import { AcceptLanguage, Filter, Page, createPage, - isUuid, processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { getShows, showFilters, showSort } from "./shows"; +import { getShow, getShows, showFilters, showSort } from "./shows"; export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .model({ @@ -37,108 +30,26 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) 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, - }, - }, - }), - }, + 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", }); } - const translation = ret.selectedTranslation[0]; - if (!translation) { + if (!ret.language) { 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, - ), - ), - }), - }; + set.headers["content-language"] = ret.language; + return ret.show; }, { detail: { diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 0d3c8c16..36c854ba 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -3,7 +3,8 @@ import { Elysia, t } from "elysia"; import { db } from "~/db"; import { shows } from "~/db/schema"; import { KError } from "~/models/error"; -import { Serie, SerieTranslation } from "~/models/serie"; +import { madeInAbyss } from "~/models/examples"; +import { FullSerie, Serie, SerieTranslation } from "~/models/serie"; import { AcceptLanguage, Filter, @@ -12,13 +13,76 @@ import { processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { getShows, showFilters, showSort } from "./shows"; +import { getShow, getShows, showFilters, showSort } from "./shows"; 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 }) => { diff --git a/api/src/controllers/shows/shows.ts b/api/src/controllers/shows/shows.ts index 44c792a7..b13bd51c 100644 --- a/api/src/controllers/shows/shows.ts +++ b/api/src/controllers/shows/shows.ts @@ -10,6 +10,7 @@ import { Genre, type Image, Sort, + isUuid, keysetPaginate, sortToSql, } from "~/models/utils"; @@ -117,3 +118,84 @@ export async function getShows({ ) .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/models/movie.ts b/api/src/models/movie.ts index 4bed67f3..da62b42a 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -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({ From d53947265fd1260c2734a32fddaba0deb567d21e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 2 Mar 2025 19:01:48 +0100 Subject: [PATCH 5/6] Add basic collection routes --- api/src/controllers/shows/collections.ts | 171 +++++++++++++++++++++ api/src/elysia.ts | 3 +- api/src/index.ts | 1 + api/src/models/collections.ts | 14 ++ api/src/models/examples/dune-collection.ts | 1 + 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 api/src/controllers/shows/collections.ts diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts new file mode 100644 index 00000000..2123dd7b --- /dev/null +++ b/api/src/controllers/shows/collections.ts @@ -0,0 +1,171 @@ +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 { + AcceptLanguage, + Filter, + Page, + createPage, + processLanguages, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { getShow, getShows, showFilters, showSort } from "./shows"; + +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, + }, + }, + ); diff --git a/api/src/elysia.ts b/api/src/elysia.ts index 80ba8ac1..7a86800a 100644 --- a/api/src/elysia.ts +++ b/api/src/elysia.ts @@ -2,10 +2,10 @@ import { Elysia } from "elysia"; import { entriesH } from "./controllers/entries"; import { seasonsH } from "./controllers/seasons"; import { seed } from "./controllers/seed"; +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: { From 9f974de245e5d4da81d6849016df3354e965060c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 2 Mar 2025 19:08:05 +0100 Subject: [PATCH 6/6] Add /collections/:id/series & /collections/:id/movies --- api/src/controllers/shows/collections.ts | 166 +++++++++++++++++- .../controllers/shows/{shows.ts => logic.ts} | 0 api/src/controllers/shows/movies.ts | 2 +- api/src/controllers/shows/series.ts | 2 +- 4 files changed, 167 insertions(+), 3 deletions(-) rename api/src/controllers/shows/{shows.ts => logic.ts} (100%) diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts index 2123dd7b..f378158b 100644 --- a/api/src/controllers/shows/collections.ts +++ b/api/src/controllers/shows/collections.ts @@ -9,15 +9,17 @@ import { } 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 "./shows"; +import { getShow, getShows, showFilters, showSort } from "./logic"; export const collections = new Elysia({ prefix: "/collections", @@ -168,4 +170,166 @@ export const collections = new Elysia({ 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/shows.ts b/api/src/controllers/shows/logic.ts similarity index 100% rename from api/src/controllers/shows/shows.ts rename to api/src/controllers/shows/logic.ts diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 10bbbec4..eb011711 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -13,7 +13,7 @@ import { processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { getShow, getShows, showFilters, showSort } from "./shows"; +import { getShow, getShows, showFilters, showSort } from "./logic"; export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .model({ diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 36c854ba..f903621e 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -13,7 +13,7 @@ import { processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { getShow, getShows, showFilters, showSort } from "./shows"; +import { getShow, getShows, showFilters, showSort } from "./logic"; export const series = new Elysia({ prefix: "/series", tags: ["series"] }) .model({