diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts new file mode 100644 index 00000000..4d7e7a27 --- /dev/null +++ b/api/src/controllers/images.ts @@ -0,0 +1,386 @@ +import { stat } from "node:fs/promises"; +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, + staff, + studioTranslations, + studios, +} 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"; + +function getRedirectToImageHandler({ + filter, +}: { + filter?: SQL; +}) { + return async function Handler({ + params: { id, image }, + headers: { "accept-language": languages }, + query: { quality }, + set, + error, + redirect, + }: { + 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 + .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: showTranslations[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 item 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", + 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." }, + }, + }, + ) + .guard({ + query: t.Object({ + quality: t.Optional( + t.UnionEnum(["high", "medium", "low"], { + default: "high", + description: "The quality you want your image to be in.", + }), + ), + }), + 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( + "/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({ + id: t.String({ + description: "The id or slug of the item to retrieve.", + example: bubble.slug, + }), + image: t.UnionEnum(["poster", "thumbnail", "logo", "banner"], { + description: "The type of image to retrive.", + }), + }), + }) + .get( + "/movies/:id/:image", + getRedirectToImageHandler({ + filter: eq(shows.kind, "movie"), + }), + { + detail: { description: "Get the specified image of a movie" }, + }, + ) + .get( + "/series/:id/:image", + getRedirectToImageHandler({ + filter: eq(shows.kind, "serie"), + }), + { + detail: { description: "Get the specified image of a serie" }, + }, + ) + .get( + "/collections/:id/:image", + getRedirectToImageHandler({ + filter: eq(shows.kind, "collection"), + }), + { + detail: { description: "Get the specified image of a collection" }, + }, + ); + +// 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/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; }); } 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...", + }, ], }, }),