diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts index b3f89e35..bd8254f3 100644 --- a/api/src/controllers/images.ts +++ b/api/src/controllers/images.ts @@ -1,53 +1,139 @@ import { stat } from "node:fs/promises"; import type { BunFile } from "bun"; +import { and, eq, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; +import { db } from "~/db"; +import { showTranslations, shows } from "~/db/schema"; +import { sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; +import { bubble } from "~/models/examples"; +import { AcceptLanguage, isUuid, processLanguages } from "~/models/utils"; import { imageDir } from "./seed/images"; -export const imagesH = new Elysia({ prefix: "/images", tags: ["images"] }).get( - ":id", - async ({ params: { id }, query: { quality }, headers: reqHeaders }) => { - const path = `${imageDir}/${id}.${quality}.jpg`; - const file = Bun.file(path); +export const imagesH = new Elysia({ tags: ["images"] }) + .get( + "/images/:id", + async ({ params: { id }, query: { quality }, headers: reqHeaders }) => { + const path = `${imageDir}/${id}.${quality}.jpg`; + const file = Bun.file(path); - const etag = await generateETag(file); - if (await isCached(reqHeaders, etag, path)) - return new Response(null, { status: 304 }); + const etag = await generateETag(file); + if (await isCached(reqHeaders, etag, path)) + return new Response(null, { status: 304 }); - const [start = 0, end = Number.POSITIVE_INFINITY] = - reqHeaders.range?.split("-").map(Number) ?? []; - return new Response(file.slice(start, end), { - headers: { - Etag: etag, - "Cache-Control": `public, max-age=${3 * 60 * 60}`, - }, - }) as any; - }, - { - detail: { description: "Access an image by id." }, - params: t.Object({ - id: t.String({ - desription: "Id of the image to retrive.", - format: "regex", - pattern: "^[0-9a-fA-F]*$", - }), - }), - query: t.Object({ - quality: t.Optional( - t.UnionEnum(["high", "medium", "low"], { - default: "high", - description: "The quality you want your image to be in.", - }), - ), - }), - response: { - 200: t.File({ description: "The whole image" }), - 206: t.File({ description: "Only the range of the image requested" }), - 304: t.Void({ description: "Cached image already up-to-date" }), - 404: { ...KError, description: "No image found with this id." }, + const [start = 0, end = Number.POSITIVE_INFINITY] = + reqHeaders.range?.split("-").map(Number) ?? []; + return new Response(file.slice(start, end), { + headers: { + Etag: etag, + "Cache-Control": `public, max-age=${3 * 60 * 60}`, + }, + }) as any; }, - }, -); + { + detail: { description: "Access an image by id." }, + params: t.Object({ + id: t.String({ + desription: "Id of the image to retrive.", + format: "regex", + pattern: "^[0-9a-fA-F]*$", + }), + }), + query: t.Object({ + quality: t.Optional( + t.UnionEnum(["high", "medium", "low"], { + default: "high", + description: "The quality you want your image to be in.", + }), + ), + }), + response: { + 200: t.File({ description: "The whole image" }), + 206: t.File({ description: "Only the range of the image requested" }), + 304: t.Void({ description: "Cached image already up-to-date" }), + 404: { ...KError, description: "No image found with this id." }, + }, + }, + ) + .get( + "/movies/:id/poster", + async ({ + params: { id }, + headers: { "accept-language": languages }, + query: { quality }, + set, + error, + redirect, + }) => { + const lang = processLanguages(languages); + const [movie] = await db + .select({ + poster: showTranslations.poster, + language: showTranslations.language, + }) + .from(shows) + .leftJoin(showTranslations, eq(shows.pk, showTranslations.pk)) + .where( + and( + eq(shows.kind, "movie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + !lang.includes("*") + ? eq(showTranslations.language, sql`any(${sqlarr(lang)})`) + : undefined, + ), + ) + .orderBy( + sql`array_position(${sqlarr(lang)}, ${showTranslations.language})`, + ) + .limit(1); + + if (!movie) { + return error(404, { + status: 404, + message: `No movie found with id or slug: '${id}'.`, + }); + } + if (!movie.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + }); + } + set.headers["content-language"] = movie.language; + return redirect(`/images/${movie.poster!.id}?quality=${quality}`); + }, + { + detail: { description: "Get the poster of a movie" }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the movie to retrieve.", + example: bubble.slug, + }), + }), + query: t.Object({ + quality: t.Optional( + t.UnionEnum(["high", "medium", "low"], { + default: "high", + description: "The quality you want your image to be in.", + }), + ), + }), + headers: t.Object({ + "accept-language": AcceptLanguage(), + }), + 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.", + }, + 422: KError, + }, + }, + ); // stolen from https://github.com/elysiajs/elysia-static/blob/main/src/cache.ts