From c114af08567df5ac181dcb4f51a1b7f26777203a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 18 Mar 2025 23:17:17 +0100 Subject: [PATCH 1/7] Properly handle image download failures --- api/src/controllers/seed/images.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/seed/images.ts b/api/src/controllers/seed/images.ts index b80d744f..0896836b 100644 --- a/api/src/controllers/seed/images.ts +++ b/api/src/controllers/seed/images.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { encode } from "blurhash"; -import { type SQL, eq, is, sql } from "drizzle-orm"; +import { type SQL, and, eq, is, lt, sql } from "drizzle-orm"; import { PgColumn, type PgTable } from "drizzle-orm/pg-core"; import { version } from "package.json"; import type { PoolClient } from "pg"; @@ -86,24 +86,32 @@ export const processImages = async () => { .select() .from(mqueue) .for("update", { skipLocked: true }) - .where(eq(mqueue.kind, "image")) - .orderBy(mqueue.createdAt) + .where(and(eq(mqueue.kind, "image"), lt(mqueue.attempt, 5))) + .orderBy(mqueue.attempt, mqueue.createdAt) .limit(1); if (!item) return false; const img = item.message as ImageTask; - const blurhash = await downloadImage(img.id, img.url); - const ret: Image = { id: img.id, source: img.url, blurhash }; + try { + const blurhash = await downloadImage(img.id, img.url); + const ret: Image = { id: img.id, source: img.url, blurhash }; - const table = sql.raw(img.table); - const column = sql.raw(img.column); + const table = sql.raw(img.table); + const column = sql.raw(img.column); - await tx.execute(sql` + await tx.execute(sql` update ${table} set ${column} = ${ret} where ${column}->'id' = ${sql.raw(`'"${img.id}"'::jsonb`)} `); - await tx.delete(mqueue).where(eq(mqueue.id, item.id)); + await tx.delete(mqueue).where(eq(mqueue.id, item.id)); + } catch (err) { + console.error("Failed to download image", img.url, err); + await tx + .update(mqueue) + .set({ attempt: sql`${mqueue.attempt}+1` }) + .where(eq(mqueue.id, item.id)); + } return true; }); } From 47554590a9646275a420c87a733ef28a1b5af4c9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 18 Mar 2025 23:21:27 +0100 Subject: [PATCH 2/7] Add image handler by id with quality control --- api/src/controllers/images.ts | 119 ++++++++++++++++++++++++++++++++++ api/src/elysia.ts | 4 +- api/src/index.ts | 8 ++- 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 api/src/controllers/images.ts diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts new file mode 100644 index 00000000..b3f89e35 --- /dev/null +++ b/api/src/controllers/images.ts @@ -0,0 +1,119 @@ +import { stat } from "node:fs/promises"; +import type { BunFile } from "bun"; +import Elysia, { t } from "elysia"; +import { KError } from "~/models/error"; +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); + + 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." }, + }, + }, +); + +// stolen from https://github.com/elysiajs/elysia-static/blob/main/src/cache.ts + +export async function isCached( + headers: Record, + etag: string, + filePath: string, +) { + // Always return stale when Cache-Control: no-cache + // to support end-to-end reload requests + // https://tools.ietf.org/html/rfc2616#section-14.9.4 + if ( + headers["cache-control"] && + headers["cache-control"].indexOf("no-cache") !== -1 + ) + return false; + + // if-none-match + if ("if-none-match" in headers) { + const ifNoneMatch = headers["if-none-match"]; + + if (ifNoneMatch === "*") return true; + + if (ifNoneMatch === null) return false; + + if (typeof etag !== "string") return false; + + const isMatching = ifNoneMatch === etag; + + if (isMatching) return true; + + /** + * A recipient MUST ignore If-Modified-Since if the request contains an + * If-None-Match header field; the condition in If-None-Match is considered + * to be a more accurate replacement for the condition in If-Modified-Since, + * and the two are only combined for the sake of interoperating with older + * intermediaries that might not implement If-None-Match. + * + * @see RFC 9110 section 13.1.3 + */ + return false; + } + + // if-modified-since + if (headers["if-modified-since"]) { + const ifModifiedSince = headers["if-modified-since"]; + let lastModified: Date | undefined; + try { + lastModified = (await stat(filePath)).mtime; + } catch { + /* empty */ + } + + if ( + lastModified !== undefined && + lastModified.getTime() <= Date.parse(ifModifiedSince) + ) + return true; + } + + return false; +} + +export async function generateETag(file: BunFile) { + const hash = new Bun.CryptoHasher("md5"); + hash.update(await file.arrayBuffer()); + + return hash.digest("base64"); +} diff --git a/api/src/elysia.ts b/api/src/elysia.ts index c5c5f59e..b1075cd5 100644 --- a/api/src/elysia.ts +++ b/api/src/elysia.ts @@ -1,5 +1,6 @@ import { Elysia } from "elysia"; import { entriesH } from "./controllers/entries"; +import { imagesH } from "./controllers/images"; import { seasonsH } from "./controllers/seasons"; import { seed } from "./controllers/seed"; import { collections } from "./controllers/shows/collections"; @@ -53,7 +54,8 @@ export const app = new Elysia() .use(collections) .use(entriesH) .use(seasonsH) - .use(videosH) .use(studiosH) .use(staffH) + .use(videosH) + .use(imagesH) .use(seed); diff --git a/api/src/index.ts b/api/src/index.ts index 9905c8a2..c1f02370 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -60,6 +60,8 @@ app { name: "movies", description: "Routes about movies" }, { name: "series", description: "Routes about series" }, { name: "collections", description: "Routes about collections" }, + { name: "studios", description: "Routes about studios" }, + { name: "staff", description: "Routes about staff & roles" }, { name: "videos", description: comment` @@ -67,8 +69,10 @@ app Can be used for administration or third party apps. `, }, - { name: "studios", description: "Routes about studios" }, - { name: "staff", description: "Routes about staff & roles" }, + { + name: "images", + description: "Routes about images: posters, thumbnails...", + }, ], }, }), From 9905587c83d65bda16510ef0db04fc1c69462fca Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 19 Mar 2025 10:00:38 +0100 Subject: [PATCH 3/7] Add first /poster route --- api/src/controllers/images.ts | 168 +++++++++++++++++++++++++--------- 1 file changed, 127 insertions(+), 41 deletions(-) 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 From 7d3413a3d58d6a01ff57de514462ffa48a4edc98 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 19 Mar 2025 14:36:12 +0100 Subject: [PATCH 4/7] Extract get image logic to a function --- api/src/controllers/images.ts | 185 +++++++++++++++++++++------------- 1 file changed, 116 insertions(+), 69 deletions(-) diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts index bd8254f3..9db450bb 100644 --- a/api/src/controllers/images.ts +++ b/api/src/controllers/images.ts @@ -1,7 +1,8 @@ import { stat } from "node:fs/promises"; import type { BunFile } from "bun"; -import { and, eq, sql } from "drizzle-orm"; -import Elysia, { t } from "elysia"; +import { type SQL, and, eq, sql } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; +import Elysia, { type InferContext, t } from "elysia"; import { db } from "~/db"; import { showTranslations, shows } from "~/db/schema"; import { sqlarr } from "~/db/utils"; @@ -10,6 +11,77 @@ import { bubble } from "~/models/examples"; import { AcceptLanguage, isUuid, processLanguages } from "~/models/utils"; import { imageDir } from "./seed/images"; +async function redirectToImage({ + image, + filter, + id, + languages, + quality, + set, + error, + redirect, +}: { + image: typeof showTranslations.poster; + filter: SQL; + id: string; + languages: string; + quality?: "high" | "medium" | "low"; + set: InferContext["set"]; + error: InferContext["error"]; + redirect: InferContext["redirect"]; +}) { + const lang = processLanguages(languages); + const item = db.$with("item").as( + db + .select({ pk: shows.pk }) + .from(shows) + .where( + and( + filter, + id !== "random" + ? isUuid(id) + ? eq(shows.id, id) + : eq(shows.slug, id) + : undefined, + ), + ) + .orderBy(sql`random()`) + .limit(1), + ); + const [ret] = await db + .with(item) + .select({ + image, + language: showTranslations.language, + }) + .from(item) + .leftJoin(showTranslations, eq(item.pk, showTranslations.pk)) + .where( + !lang.includes("*") + ? eq(showTranslations.language, sql`any(${sqlarr(lang)})`) + : undefined, + ) + .orderBy(sql`array_position(${sqlarr(lang)}, ${showTranslations.language})`) + .limit(1); + + if (!ret) { + return error(404, { + status: 404, + message: `No movie found with id or slug: '${id}'.`, + }); + } + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + }); + } + set.headers["content-language"] = ret.language; + return quality + ? redirect(`/images/${ret.image!.id}?quality=${quality}`) + : redirect(`/images/${ret.image!.id}`); +} + export const imagesH = new Elysia({ tags: ["images"] }) .get( "/images/:id", @@ -55,83 +127,58 @@ export const imagesH = new Elysia({ tags: ["images"] }) }, }, ) + .guard({ + params: t.Object({ + id: t.String({ + description: "The id or slug of the item 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 [/images/{id}](#tag/images/GET/images/{id}) route.", + }), + 404: { + ...KError, + description: "No item found with the given id or slug.", + }, + 422: KError, + }, + }) .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}`); - }, + }) => + redirectToImage({ + filter: eq(shows.kind, "movie"), + image: showTranslations.poster, + id, + languages, + quality, + set, + error, + redirect, + }), { 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, - }, }, ); From 6ff00a11338de0b89991a3a1bfa7c8bd139eaa74 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 19 Mar 2025 14:55:55 +0100 Subject: [PATCH 5/7] Add all movies image routes --- api/src/controllers/images.ts | 189 +++++++++++++++++++--------------- 1 file changed, 108 insertions(+), 81 deletions(-) diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts index 9db450bb..d8a76591 100644 --- a/api/src/controllers/images.ts +++ b/api/src/controllers/images.ts @@ -2,84 +2,95 @@ import { stat } from "node:fs/promises"; import type { BunFile } from "bun"; import { type SQL, and, eq, sql } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; -import Elysia, { type InferContext, t } from "elysia"; +import Elysia, { type Context, 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 { + AcceptLanguage, + type Image, + isUuid, + processLanguages, +} from "~/models/utils"; import { imageDir } from "./seed/images"; -async function redirectToImage({ +function getRedirectToImageHandler({ image, filter, - id, - languages, - quality, - set, - error, - redirect, }: { - image: typeof showTranslations.poster; + image: PgColumn; filter: SQL; - id: string; - languages: string; - quality?: "high" | "medium" | "low"; - set: InferContext["set"]; - error: InferContext["error"]; - redirect: InferContext["redirect"]; }) { - const lang = processLanguages(languages); - const item = db.$with("item").as( - db - .select({ pk: shows.pk }) - .from(shows) + return async function Handler({ + params: { id }, + headers: { "accept-language": languages }, + query: { quality }, + set, + error, + redirect, + }: { + params: { id: string }; + headers: { "accept-language": string }; + query: { quality: "high" | "medium" | "low" }; + set: Context["set"]; + error: Context["error"]; + redirect: Context["redirect"]; + }) { + const lang = processLanguages(languages); + const item = db.$with("item").as( + db + .select({ pk: shows.pk }) + .from(shows) + .where( + and( + filter, + id !== "random" + ? isUuid(id) + ? eq(shows.id, id) + : eq(shows.slug, id) + : undefined, + ), + ) + .orderBy(sql`random()`) + .limit(1), + ); + const [ret] = await db + .with(item) + .select({ + image, + language: showTranslations.language, + }) + .from(item) + .leftJoin(showTranslations, eq(item.pk, showTranslations.pk)) .where( - and( - filter, - id !== "random" - ? isUuid(id) - ? eq(shows.id, id) - : eq(shows.slug, id) - : undefined, - ), + !lang.includes("*") + ? eq(showTranslations.language, sql`any(${sqlarr(lang)})`) + : undefined, ) - .orderBy(sql`random()`) - .limit(1), - ); - const [ret] = await db - .with(item) - .select({ - image, - language: showTranslations.language, - }) - .from(item) - .leftJoin(showTranslations, eq(item.pk, showTranslations.pk)) - .where( - !lang.includes("*") - ? eq(showTranslations.language, sql`any(${sqlarr(lang)})`) - : undefined, - ) - .orderBy(sql`array_position(${sqlarr(lang)}, ${showTranslations.language})`) - .limit(1); + .orderBy( + sql`array_position(${sqlarr(lang)}, ${showTranslations.language})`, + ) + .limit(1); - if (!ret) { - return error(404, { - status: 404, - message: `No movie found with id or slug: '${id}'.`, - }); - } - if (!ret.language) { - return error(422, { - status: 422, - message: "Accept-Language header could not be satisfied.", - }); - } - set.headers["content-language"] = ret.language; - return quality - ? redirect(`/images/${ret.image!.id}?quality=${quality}`) - : redirect(`/images/${ret.image!.id}`); + if (!ret) { + return error(404, { + status: 404, + message: `No movie found with id or slug: '${id}'.`, + }); + } + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + }); + } + set.headers["content-language"] = ret.language; + return quality + ? redirect(`/images/${ret.image!.id}?quality=${quality}`) + : redirect(`/images/${ret.image!.id}`); + }; } export const imagesH = new Elysia({ tags: ["images"] }) @@ -159,27 +170,43 @@ export const imagesH = new Elysia({ tags: ["images"] }) }) .get( "/movies/:id/poster", - ({ - params: { id }, - headers: { "accept-language": languages }, - query: { quality }, - set, - error, - redirect, - }) => - redirectToImage({ - filter: eq(shows.kind, "movie"), - image: showTranslations.poster, - id, - languages, - quality, - set, - error, - redirect, - }), + getRedirectToImageHandler({ + filter: eq(shows.kind, "movie"), + image: showTranslations.poster, + }), { detail: { description: "Get the poster of a movie" }, }, + ) + .get( + "/movies/:id/thumbnail", + getRedirectToImageHandler({ + filter: eq(shows.kind, "movie"), + image: showTranslations.thumbnail, + }), + { + detail: { description: "Get the thumbnail of a movie" }, + }, + ) + .get( + "/movies/:id/logo", + getRedirectToImageHandler({ + filter: eq(shows.kind, "movie"), + image: showTranslations.logo, + }), + { + detail: { description: "Get the logo of a movie" }, + }, + ) + .get( + "/movies/:id/banner", + getRedirectToImageHandler({ + filter: eq(shows.kind, "movie"), + image: showTranslations.banner, + }), + { + detail: { description: "Get the banner of a movie" }, + }, ); // stolen from https://github.com/elysiajs/elysia-static/blob/main/src/cache.ts From 27c5b34c5a9dd4c5f0f783ff20fa2aa065620fc5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 19 Mar 2025 15:30:01 +0100 Subject: [PATCH 6/7] Cleanup shows route & handle random --- api/src/controllers/images.ts | 65 +++++++++++++++-------------------- 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts index d8a76591..291256a9 100644 --- a/api/src/controllers/images.ts +++ b/api/src/controllers/images.ts @@ -1,43 +1,36 @@ import { stat } from "node:fs/promises"; import type { BunFile } from "bun"; import { type SQL, and, eq, sql } from "drizzle-orm"; -import type { PgColumn } from "drizzle-orm/pg-core"; import Elysia, { type Context, 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, - type Image, - isUuid, - processLanguages, -} from "~/models/utils"; +import { AcceptLanguage, isUuid, processLanguages } from "~/models/utils"; import { imageDir } from "./seed/images"; function getRedirectToImageHandler({ - image, filter, }: { - image: PgColumn; - filter: SQL; + filter?: SQL; }) { return async function Handler({ - params: { id }, + params: { id, image }, headers: { "accept-language": languages }, query: { quality }, set, error, redirect, }: { - params: { id: string }; + params: { id: string; image: "poster" | "thumbnail" | "banner" | "logo" }; headers: { "accept-language": string }; query: { quality: "high" | "medium" | "low" }; set: Context["set"]; error: Context["error"]; redirect: Context["redirect"]; }) { + id ??= "random"; const lang = processLanguages(languages); const item = db.$with("item").as( db @@ -59,7 +52,7 @@ function getRedirectToImageHandler({ const [ret] = await db .with(item) .select({ - image, + image: showTranslations[image], language: showTranslations.language, }) .from(item) @@ -140,10 +133,7 @@ export const imagesH = new Elysia({ tags: ["images"] }) ) .guard({ params: t.Object({ - id: t.String({ - description: "The id or slug of the item to retrieve.", - example: bubble.slug, - }), + image: t.UnionEnum(["poster", "thumbnail", "logo", "banner"]), }), query: t.Object({ quality: t.Optional( @@ -168,44 +158,43 @@ export const imagesH = new Elysia({ tags: ["images"] }) 422: KError, }, }) + .get("/shows/random/:image", getRedirectToImageHandler({}), { + detail: { description: "Get the specified image of a random show." }, + }) + .guard({ + params: t.Object({ + id: t.String({ + description: "The id or slug of the item to retrieve.", + example: bubble.slug, + }), + image: t.UnionEnum(["poster", "thumbnail", "logo", "banner"]), + }), + }) .get( - "/movies/:id/poster", + "/movies/:id/:image", getRedirectToImageHandler({ filter: eq(shows.kind, "movie"), - image: showTranslations.poster, }), { - detail: { description: "Get the poster of a movie" }, + detail: { description: "Get the specified image of a movie" }, }, ) .get( - "/movies/:id/thumbnail", + "/series/:id/:image", getRedirectToImageHandler({ - filter: eq(shows.kind, "movie"), - image: showTranslations.thumbnail, + filter: eq(shows.kind, "serie"), }), { - detail: { description: "Get the thumbnail of a movie" }, + detail: { description: "Get the specified image of a serie" }, }, ) .get( - "/movies/:id/logo", + "/collections/:id/:image", getRedirectToImageHandler({ - filter: eq(shows.kind, "movie"), - image: showTranslations.logo, + filter: eq(shows.kind, "collection"), }), { - detail: { description: "Get the logo of a movie" }, - }, - ) - .get( - "/movies/:id/banner", - getRedirectToImageHandler({ - filter: eq(shows.kind, "movie"), - image: showTranslations.banner, - }), - { - detail: { description: "Get the banner of a movie" }, + detail: { description: "Get the specified image of a collection" }, }, ); From 4ce8ce7f6dcbbb2e54b934b6ea7771c628eb5e58 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 19 Mar 2025 18:18:50 +0100 Subject: [PATCH 7/7] Add /studios/:id/logo & /staff/:id/image --- api/src/controllers/images.ts | 136 +++++++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts index 291256a9..4d7e7a27 100644 --- a/api/src/controllers/images.ts +++ b/api/src/controllers/images.ts @@ -3,7 +3,13 @@ import type { BunFile } from "bun"; import { type SQL, and, eq, sql } from "drizzle-orm"; import Elysia, { type Context, t } from "elysia"; import { db } from "~/db"; -import { showTranslations, shows } from "~/db/schema"; +import { + showTranslations, + shows, + staff, + studioTranslations, + studios, +} from "~/db/schema"; import { sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; import { bubble } from "~/models/examples"; @@ -70,7 +76,7 @@ function getRedirectToImageHandler({ if (!ret) { return error(404, { status: 404, - message: `No movie found with id or slug: '${id}'.`, + message: `No item found with id or slug: '${id}'.`, }); } if (!ret.language) { @@ -132,9 +138,6 @@ export const imagesH = new Elysia({ tags: ["images"] }) }, ) .guard({ - params: t.Object({ - image: t.UnionEnum(["poster", "thumbnail", "logo", "banner"]), - }), query: t.Object({ quality: t.Optional( t.UnionEnum(["high", "medium", "low"], { @@ -143,9 +146,6 @@ export const imagesH = new Elysia({ tags: ["images"] }) }), ), }), - headers: t.Object({ - "accept-language": AcceptLanguage(), - }), response: { 302: t.Void({ description: @@ -158,8 +158,124 @@ export const imagesH = new Elysia({ tags: ["images"] }) 422: KError, }, }) + .get( + "/staff/:id/image", + async ({ params: { id }, query: { quality }, error, redirect }) => { + const [ret] = await db + .select({ image: staff.image }) + .from(staff) + .where( + id !== "random" + ? isUuid(id) + ? eq(shows.id, id) + : eq(shows.slug, id) + : undefined, + ) + .orderBy(sql`random()`) + .limit(1); + + if (!ret) { + return error(404, { + status: 404, + message: `No staff member found with id or slug: '${id}'.`, + }); + } + return quality + ? redirect(`/images/${ret.image!.id}?quality=${quality}`) + : redirect(`/images/${ret.image!.id}`); + }, + { + detail: { description: "Get the image of a staff member." }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the staff member.", + example: bubble.slug, + }), + }), + }, + ) + .guard({ + headers: t.Object({ + "accept-language": AcceptLanguage(), + }), + }) + .get( + "/studios/:id/logo", + async ({ + params: { id }, + headers: { "accept-language": languages }, + query: { quality }, + set, + error, + redirect, + }) => { + const lang = processLanguages(languages); + const item = db.$with("item").as( + db + .select({ pk: studios.pk }) + .from(studios) + .where( + id !== "random" + ? isUuid(id) + ? eq(studios.id, id) + : eq(studios.slug, id) + : undefined, + ) + .orderBy(sql`random()`) + .limit(1), + ); + const [ret] = await db + .with(item) + .select({ + image: studioTranslations.logo, + language: studioTranslations.language, + }) + .from(item) + .leftJoin(studioTranslations, eq(item.pk, studioTranslations.pk)) + .where( + !lang.includes("*") + ? eq(studioTranslations.language, sql`any(${sqlarr(lang)})`) + : undefined, + ) + .orderBy( + sql`array_position(${sqlarr(lang)}, ${studioTranslations.language})`, + ) + .limit(1); + + if (!ret) { + return error(404, { + status: 404, + message: `No studio found with id or slug: '${id}'.`, + }); + } + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + }); + } + set.headers["content-language"] = ret.language; + return quality + ? redirect(`/images/${ret.image!.id}?quality=${quality}`) + : redirect(`/images/${ret.image!.id}`); + }, + { + detail: { description: "Get the logo of a studio." }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the studio.", + example: bubble.slug, + }), + }), + }, + ) .get("/shows/random/:image", getRedirectToImageHandler({}), { detail: { description: "Get the specified image of a random show." }, + params: t.Object({ + image: t.UnionEnum(["poster", "thumbnail", "logo", "banner"], { + description: "The type of image to retrive.", + }), + }), }) .guard({ params: t.Object({ @@ -167,7 +283,9 @@ export const imagesH = new Elysia({ tags: ["images"] }) description: "The id or slug of the item to retrieve.", example: bubble.slug, }), - image: t.UnionEnum(["poster", "thumbnail", "logo", "banner"]), + image: t.UnionEnum(["poster", "thumbnail", "logo", "banner"], { + description: "The type of image to retrive.", + }), }), }) .get(