mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-11-04 03:27:14 -05:00 
			
		
		
		
	Add routes to access images (#852)
This commit is contained in:
		
						commit
						b77aa39770
					
				
							
								
								
									
										386
									
								
								api/src/controllers/images.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										386
									
								
								api/src/controllers/images.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string, string | undefined>,
 | 
			
		||||
	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");
 | 
			
		||||
}
 | 
			
		||||
@ -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,13 +86,14 @@ 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;
 | 
			
		||||
			try {
 | 
			
		||||
				const blurhash = await downloadImage(img.id, img.url);
 | 
			
		||||
				const ret: Image = { id: img.id, source: img.url, blurhash };
 | 
			
		||||
 | 
			
		||||
@ -104,6 +105,13 @@ export const processImages = async () => {
 | 
			
		||||
			`);
 | 
			
		||||
 | 
			
		||||
				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;
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
@ -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...",
 | 
			
		||||
					},
 | 
			
		||||
				],
 | 
			
		||||
			},
 | 
			
		||||
		}),
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user