diff --git a/api/src/base.ts b/api/src/base.ts index 0acbd54b..a0f45726 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -23,5 +23,7 @@ export const base = new Elysia({ name: "base" }) details: error, } as KError; } + console.error(code, error) + return error; }) .as("plugin"); diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index e20ad0f2..9e814f70 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -3,10 +3,10 @@ import { Elysia, t } from "elysia"; import { KError } from "~/models/error"; import { comment } from "~/utils"; import { db } from "../db"; -import { shows, showTranslations } from "../db/schema/shows"; -import { getColumns } from "../db/schema/utils"; -import { bubble } from "../models/examples"; -import { Movie, MovieStatus, MovieTranslation } from "../models/movie"; +import { shows, showTranslations } from "~/db/schema"; +import { getColumns, sqlarr } from "~/db/schema/utils"; +import { bubble } from "~/models/examples"; +import { Movie, MovieStatus, MovieTranslation } from "~/models/movie"; import { Filter, type Image, @@ -20,34 +20,6 @@ import { createPage, } from "~/models/utils"; -// drizzle is bugged and doesn't allow js arrays to be used in raw sql. -export function sqlarr(array: unknown[]) { - return `{${array.map((item) => `"${item}"`).join(",")}}`; -} - -const getTranslationQuery = (languages: string[], forceFallback = false) => { - const fallback = forceFallback || languages.includes("*"); - const query = db - .selectDistinctOn([showTranslations.pk]) - .from(showTranslations) - .where( - fallback - ? undefined - : eq(showTranslations.language, sql`any(${sqlarr(languages)})`), - ) - .orderBy( - showTranslations.pk, - sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, - ) - .as("t"); - - const { pk, ...col } = getColumns(query); - return [query, col] as const; -}; - -// we keep the pk for after handling. it will be removed by elysia's validators after. -const { kind, startAir, endAir, ...moviesCol } = getColumns(shows); - const movieFilters: FilterDef = { genres: { column: shows.genres, @@ -72,25 +44,39 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) async ({ params: { id }, headers: { "accept-language": languages }, + query: { preferOriginal }, error, set, }) => { const langs = processLanguages(languages); - const [transQ, transCol] = getTranslationQuery(langs); - const idFilter = isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id); - - const [ret] = await db - .select({ - ...moviesCol, - status: sql`${moviesCol.status}`, - airDate: startAir, - translation: transCol, - }) - .from(shows) - .leftJoin(transQ, eq(shows.pk, transQ.pk)) - .where(and(eq(shows.kind, "movie"), idFilter)) - .limit(1); + 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"), + }, + where: and( + eq(shows.kind, "movie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + ), + with: { + translations: { + columns: { + pk: false, + }, + where: eq(showTranslations.language, sql`any(${sqlarr(langs)})`), + orderBy: [ + sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`, + ], + limit: 1, + }, + }, + }); if (!ret) { return error(404, { @@ -98,14 +84,15 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) message: "Movie not found", }); } - if (!ret.translation) { + const translation = ret.translations[0]; + if (!translation) { return error(422, { status: 422, message: "Accept-Language header could not be satisfied.", }); } - set.headers["content-language"] = ret.translation.language; - return { ...ret, ...ret.translation }; + set.headers["content-language"] = translation.language; + return { ...ret, ...translation }; }, { detail: { @@ -117,6 +104,17 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) example: bubble.slug, }), }), + query: t.Object({ + preferOriginal: t.Optional( + t.Boolean({ + description: comment` + Prefer images in the original's language. If true, will return untranslated images instead of the translated ones. + + If unspecified, kyoo will look at the current user's settings to decide what to do. + `, + }), + ), + }), headers: t.Object({ "accept-language": t.String({ default: "*", @@ -197,11 +195,13 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) => { 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]) + .select() .from(showTranslations) .orderBy( - showTranslations.pk, sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`, ) .as("t"); @@ -214,10 +214,10 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) ...transCol, status: sql`${moviesCol.status}`, airDate: startAir, - poster: sql`coalese(${showTranslations.poster}, ${poster})`, - thumbnail: sql`coalese(${showTranslations.thumbnail}, ${thumbnail})`, - banner: sql`coalese(${showTranslations.banner}, ${banner})`, - logo: sql`coalese(${showTranslations.logo}, ${logo})`, + 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)) @@ -227,7 +227,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) eq(shows.pk, showTranslations.pk), eq(showTranslations.language, shows.originalLanguage), // TODO: check user's settings before fallbacking to false. - sql`coalese(${preferOriginal}, false)`, + sql`coalesce(${preferOriginal ?? null}::boolean, false)`, ), ) .where(and(filter, keysetPaginate({ table: shows, after, sort }))) @@ -267,7 +267,15 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) `, }), ), - preferOriginal: t.Optional(t.Boolean()), + preferOriginal: t.Optional( + t.Boolean({ + description: comment` + Prefer images in the original's language. If true, will return untranslated images instead of the translated ones. + + If unspecified, kyoo will look at the current user's settings to decide what to do. + `, + }), + ), }), headers: t.Object({ "accept-language": t.String({ diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index dae8a801..55d13b57 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -77,3 +77,8 @@ export function conflictUpdateAllExcept< {} as Omit, E[number]>, ); } + +// drizzle is bugged and doesn't allow js arrays to be used in raw sql. +export function sqlarr(array: unknown[]) { + return `{${array.map((item) => `"${item}"`).join(",")}}`; +} diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 27b469ad..d1583d0e 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -19,8 +19,22 @@ export const bubble: SeedMovie = { tagline: "Is she a calamity or a blessing?", description: "In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl.", - aliases: ["Baburu", "バブル:2022", "Bubble"], + aliases: ["Baburu", "Bubble"], tags: ["adolescence", "disaster", "battle", "gravity", "anime"], + poster: + "https://image.tmdb.org/t/p/original/kk28Lk8oQBGjoHRGUCN2vxKb4O2.jpg", + thumbnail: + "https://image.tmdb.org/t/p/original/a8Q2g0g7XzAF6gcB8qgn37ccb9Y.jpg", + banner: null, + logo: null, + trailerUrl: "https://www.youtube.com/watch?v=vs7zsyIZkMM", + }, + jp: { + name: "バブル:2022", + tagline: null, + description: null, + aliases: ["Baburu", "Bubble"], + tags: ["アニメ"], poster: "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", thumbnail: