Rewrite /movies/{id} to use the relational api

This commit is contained in:
Zoe Roux 2025-01-16 23:14:13 +01:00
parent 5ca1b19148
commit f3f69a0def
4 changed files with 87 additions and 58 deletions

View File

@ -23,5 +23,7 @@ export const base = new Elysia({ name: "base" })
details: error, details: error,
} as KError; } as KError;
} }
console.error(code, error)
return error;
}) })
.as("plugin"); .as("plugin");

View File

@ -3,10 +3,10 @@ import { Elysia, t } from "elysia";
import { KError } from "~/models/error"; import { KError } from "~/models/error";
import { comment } from "~/utils"; import { comment } from "~/utils";
import { db } from "../db"; import { db } from "../db";
import { shows, showTranslations } from "../db/schema/shows"; import { shows, showTranslations } from "~/db/schema";
import { getColumns } from "../db/schema/utils"; import { getColumns, sqlarr } from "~/db/schema/utils";
import { bubble } from "../models/examples"; import { bubble } from "~/models/examples";
import { Movie, MovieStatus, MovieTranslation } from "../models/movie"; import { Movie, MovieStatus, MovieTranslation } from "~/models/movie";
import { import {
Filter, Filter,
type Image, type Image,
@ -20,34 +20,6 @@ import {
createPage, createPage,
} from "~/models/utils"; } 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 = { const movieFilters: FilterDef = {
genres: { genres: {
column: shows.genres, column: shows.genres,
@ -72,25 +44,39 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
async ({ async ({
params: { id }, params: { id },
headers: { "accept-language": languages }, headers: { "accept-language": languages },
query: { preferOriginal },
error, error,
set, set,
}) => { }) => {
const langs = processLanguages(languages); 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.query.shows.findFirst({
columns: {
const [ret] = await db kind: false,
.select({ startAir: false,
...moviesCol, endAir: false,
status: sql<MovieStatus>`${moviesCol.status}`, },
airDate: startAir, extras: {
translation: transCol, airDate: sql<string>`${shows.startAir}`.as("airDate"),
}) status: sql<MovieStatus>`${shows.status}`.as("status"),
.from(shows) },
.leftJoin(transQ, eq(shows.pk, transQ.pk)) where: and(
.where(and(eq(shows.kind, "movie"), idFilter)) eq(shows.kind, "movie"),
.limit(1); 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) { if (!ret) {
return error(404, { return error(404, {
@ -98,14 +84,15 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
message: "Movie not found", message: "Movie not found",
}); });
} }
if (!ret.translation) { const translation = ret.translations[0];
if (!translation) {
return error(422, { return error(422, {
status: 422, status: 422,
message: "Accept-Language header could not be satisfied.", message: "Accept-Language header could not be satisfied.",
}); });
} }
set.headers["content-language"] = ret.translation.language; set.headers["content-language"] = translation.language;
return { ...ret, ...ret.translation }; return { ...ret, ...translation };
}, },
{ {
detail: { detail: {
@ -117,6 +104,17 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
example: bubble.slug, 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({ headers: t.Object({
"accept-language": t.String({ "accept-language": t.String({
default: "*", default: "*",
@ -197,11 +195,13 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
}) => { }) => {
const langs = processLanguages(languages); 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 const transQ = db
.selectDistinctOn([showTranslations.pk]) .select()
.from(showTranslations) .from(showTranslations)
.orderBy( .orderBy(
showTranslations.pk,
sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`, sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`,
) )
.as("t"); .as("t");
@ -214,10 +214,10 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
...transCol, ...transCol,
status: sql<MovieStatus>`${moviesCol.status}`, status: sql<MovieStatus>`${moviesCol.status}`,
airDate: startAir, airDate: startAir,
poster: sql<Image>`coalese(${showTranslations.poster}, ${poster})`, poster: sql<Image>`coalesce(${showTranslations.poster}, ${poster})`,
thumbnail: sql<Image>`coalese(${showTranslations.thumbnail}, ${thumbnail})`, thumbnail: sql<Image>`coalesce(${showTranslations.thumbnail}, ${thumbnail})`,
banner: sql<Image>`coalese(${showTranslations.banner}, ${banner})`, banner: sql<Image>`coalesce(${showTranslations.banner}, ${banner})`,
logo: sql<Image>`coalese(${showTranslations.logo}, ${logo})`, logo: sql<Image>`coalesce(${showTranslations.logo}, ${logo})`,
}) })
.from(shows) .from(shows)
.innerJoin(transQ, eq(shows.pk, transQ.pk)) .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(shows.pk, showTranslations.pk),
eq(showTranslations.language, shows.originalLanguage), eq(showTranslations.language, shows.originalLanguage),
// TODO: check user's settings before fallbacking to false. // 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 }))) .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({ headers: t.Object({
"accept-language": t.String({ "accept-language": t.String({

View File

@ -77,3 +77,8 @@ export function conflictUpdateAllExcept<
{} as Omit<Record<keyof T["_"]["columns"], SQL>, E[number]>, {} as Omit<Record<keyof T["_"]["columns"], SQL>, 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(",")}}`;
}

View File

@ -19,8 +19,22 @@ export const bubble: SeedMovie = {
tagline: "Is she a calamity or a blessing?", tagline: "Is she a calamity or a blessing?",
description: description:
"In an abandoned Tokyo overrun by bubbles and gravitational abnormalities, one gifted young man has a fateful meeting with a mysterious girl.", "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"], 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: poster:
"https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg", "https://image.tmdb.org/t/p/original/65dad96VE8FJPEdrAkhdsuWMWH9.jpg",
thumbnail: thumbnail: