wip: Add image downloading

This commit is contained in:
Zoe Roux 2025-03-15 22:24:25 +01:00
parent 82d8a00eb4
commit 1a11bc3492
No known key found for this signature in database
12 changed files with 123 additions and 40 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "api", "name": "api",
"version": "1.0.50", "version": "5.0.0",
"scripts": { "scripts": {
"dev": "bun --watch src/index.ts", "dev": "bun --watch src/index.ts",
"build": "bun build src/index.ts --target bun --outdir ./dist", "build": "bun build src/index.ts --target bun --outdir ./dist",

View File

@ -1,22 +1,74 @@
import { eq, sql } from "drizzle-orm";
import { version } from "package.json";
import type { PoolClient } from "pg";
import { db } from "~/db";
import * as schema from "~/db/schema";
import { mqueue } from "~/db/schema/queue";
import type { Image } from "~/models/utils"; import type { Image } from "~/models/utils";
type ImageTask = {
id: string;
url: string;
table: string;
column: string;
};
// this will only push a task to the image downloader service and not download it instantly. // this will only push a task to the image downloader service and not download it instantly.
// this is both done to prevent to many requests to be sent at once and to make sure POST // this is both done to prevent to many requests to be sent at once and to make sure POST
// requests are not blocked by image downloading or blurhash calculation // requests are not blocked by image downloading or blurhash calculation
export const processImage = (url: string): Image => { export const enqueueImage = async (
tx: typeof db,
url: string,
): Promise<Image> => {
const hasher = new Bun.CryptoHasher("sha256"); const hasher = new Bun.CryptoHasher("sha256");
hasher.update(url); hasher.update(url);
const id = hasher.digest().toString("hex");
// TODO: download source, save it in multiples qualities & process blurhash await tx.insert(mqueue).values({ kind: "image", message: { id, url } });
return { return {
id: hasher.digest().toString("hex"), id,
source: url, source: url,
blurhash: "", blurhash: "",
}; };
}; };
export const processOptImage = (url: string | null): Image | null => { export const enqueueOptImage = async (
tx: typeof db,
url: string | null,
): Promise<Image | null> => {
if (!url) return null; if (!url) return null;
return processImage(url); return await enqueueImage(tx, url);
};
export const processImages = async () => {
await db.transaction(async (tx) => {
const [item] = await tx
.select()
.from(mqueue)
.for("update", { skipLocked: true })
.where(eq(mqueue.kind, "image"))
.orderBy(mqueue.createdAt)
.limit(1);
const img = item.message as ImageTask;
await fetch(img.url, { headers: { "User-Agent": `Kyoo v${version}` }, });
const blurhash = "";
const table = schema[img.table as keyof typeof schema] as any;
await tx
.update(table)
.set({
[img.column]: { id: img.id, source: img.url, blurhash } satisfies Image,
})
.where(eq(sql`${table[img.column]}->'id'`, img.id));
await tx.delete(mqueue).where(eq(mqueue.id, item.id));
});
const client = (await db.$client.connect()) as PoolClient;
client.on("notification", (evt) => {
if (evt.channel !== "image") return;
});
}; };

View File

@ -5,7 +5,7 @@ import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedCollection } from "~/models/collections"; import type { SeedCollection } from "~/models/collections";
import type { SeedMovie } from "~/models/movie"; import type { SeedMovie } from "~/models/movie";
import type { SeedSerie } from "~/models/serie"; import type { SeedSerie } from "~/models/serie";
import { processOptImage } from "../images"; import { enqueueOptImage } from "../images";
type ShowTrans = typeof showTranslations.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert;
@ -53,10 +53,10 @@ export const insertCollection = async (
pk: ret.pk, pk: ret.pk,
language: lang, language: lang,
...tr, ...tr,
poster: processOptImage(tr.poster), poster: enqueueOptImage(tr.poster),
thumbnail: processOptImage(tr.thumbnail), thumbnail: enqueueOptImage(tr.thumbnail),
logo: processOptImage(tr.logo), logo: enqueueOptImage(tr.logo),
banner: processOptImage(tr.banner), banner: enqueueOptImage(tr.banner),
}), }),
); );
await tx await tx

View File

@ -8,7 +8,7 @@ import {
} from "~/db/schema"; } from "~/db/schema";
import { conflictUpdateAllExcept, sqlarr, values } from "~/db/utils"; import { conflictUpdateAllExcept, sqlarr, values } from "~/db/utils";
import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry"; import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry";
import { processOptImage } from "../images"; import { enqueueOptImage } from "../images";
import { guessNextRefresh } from "../refresh"; import { guessNextRefresh } from "../refresh";
import { updateAvailableCount } from "./shows"; import { updateAvailableCount } from "./shows";
@ -55,7 +55,7 @@ export const insertEntries = async (
...entry, ...entry,
showPk: show.pk, showPk: show.pk,
slug: generateSlug(show.slug, seed), slug: generateSlug(show.slug, seed),
thumbnail: processOptImage(seed.thumbnail), thumbnail: enqueueOptImage(seed.thumbnail),
nextRefresh: nextRefresh:
entry.kind !== "extra" entry.kind !== "extra"
? guessNextRefresh(entry.airDate ?? new Date()) ? guessNextRefresh(entry.airDate ?? new Date())
@ -103,7 +103,7 @@ export const insertEntries = async (
...tr, ...tr,
poster: poster:
seed.kind === "movie" seed.kind === "movie"
? processOptImage((tr as any).poster) ? enqueueOptImage((tr as any).poster)
: undefined, : undefined,
})); }));
}); });

View File

@ -2,7 +2,7 @@ import { db } from "~/db";
import { seasonTranslations, seasons } from "~/db/schema"; import { seasonTranslations, seasons } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils"; import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedSeason } from "~/models/season"; import type { SeedSeason } from "~/models/season";
import { processOptImage } from "../images"; import { enqueueOptImage } from "../images";
import { guessNextRefresh } from "../refresh"; import { guessNextRefresh } from "../refresh";
type SeasonI = typeof seasons.$inferInsert; type SeasonI = typeof seasons.$inferInsert;
@ -43,9 +43,9 @@ export const insertSeasons = async (
pk: ret[i].pk, pk: ret[i].pk,
language: lang, language: lang,
...tr, ...tr,
poster: processOptImage(tr.poster), poster: enqueueOptImage(tr.poster),
thumbnail: processOptImage(tr.thumbnail), thumbnail: enqueueOptImage(tr.thumbnail),
banner: processOptImage(tr.banner), banner: enqueueOptImage(tr.banner),
})), })),
); );
await tx await tx

View File

@ -6,7 +6,7 @@ import type { SeedCollection } from "~/models/collections";
import type { SeedMovie } from "~/models/movie"; import type { SeedMovie } from "~/models/movie";
import type { SeedSerie } from "~/models/serie"; import type { SeedSerie } from "~/models/serie";
import { getYear } from "~/utils"; import { getYear } from "~/utils";
import { processOptImage } from "../images"; import { enqueueOptImage } from "../images";
type Show = typeof shows.$inferInsert; type Show = typeof shows.$inferInsert;
type ShowTrans = typeof showTranslations.$inferInsert; type ShowTrans = typeof showTranslations.$inferInsert;
@ -27,10 +27,10 @@ export const insertShow = async (
pk: ret.pk, pk: ret.pk,
language: lang, language: lang,
...tr, ...tr,
poster: processOptImage(tr.poster), poster: enqueueOptImage(tr.poster),
thumbnail: processOptImage(tr.thumbnail), thumbnail: enqueueOptImage(tr.thumbnail),
logo: processOptImage(tr.logo), logo: enqueueOptImage(tr.logo),
banner: processOptImage(tr.banner), banner: enqueueOptImage(tr.banner),
}), }),
); );
await tx await tx

View File

@ -3,7 +3,7 @@ import { db } from "~/db";
import { roles, staff } from "~/db/schema"; import { roles, staff } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils"; import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedStaff } from "~/models/staff"; import type { SeedStaff } from "~/models/staff";
import { processOptImage } from "../images"; import { enqueueOptImage } from "../images";
export const insertStaff = async ( export const insertStaff = async (
seed: SeedStaff[] | undefined, seed: SeedStaff[] | undefined,
@ -14,7 +14,7 @@ export const insertStaff = async (
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const people = seed.map((x) => ({ const people = seed.map((x) => ({
...x.staff, ...x.staff,
image: processOptImage(x.staff.image), image: enqueueOptImage(x.staff.image),
})); }));
const ret = await tx const ret = await tx
.insert(staff) .insert(staff)
@ -32,7 +32,7 @@ export const insertStaff = async (
order: i, order: i,
character: { character: {
...x.character, ...x.character,
image: processOptImage(x.character.image), image: enqueueOptImage(x.character.image),
}, },
})); }));

View File

@ -2,7 +2,7 @@ import { db } from "~/db";
import { showStudioJoin, studioTranslations, studios } from "~/db/schema"; import { showStudioJoin, studioTranslations, studios } from "~/db/schema";
import { conflictUpdateAllExcept } from "~/db/utils"; import { conflictUpdateAllExcept } from "~/db/utils";
import type { SeedStudio } from "~/models/studio"; import type { SeedStudio } from "~/models/studio";
import { processOptImage } from "../images"; import { enqueueOptImage } from "../images";
type StudioI = typeof studios.$inferInsert; type StudioI = typeof studios.$inferInsert;
type StudioTransI = typeof studioTranslations.$inferInsert; type StudioTransI = typeof studioTranslations.$inferInsert;
@ -38,7 +38,7 @@ export const insertStudios = async (
pk: ret[i].pk, pk: ret[i].pk,
language: lang, language: lang,
name: tr.name, name: tr.name,
logo: processOptImage(tr.logo), logo: enqueueOptImage(tr.logo),
})), })),
); );
await tx await tx

View File

@ -1,7 +1,7 @@
import { t } from "elysia"; import { t } from "elysia";
import type { SeedMovie } from "~/models/movie"; import type { SeedMovie } from "~/models/movie";
import { getYear } from "~/utils"; import { getYear } from "~/utils";
import { processOptImage } from "./images"; import { enqueueOptImage } from "./images";
import { insertCollection } from "./insert/collection"; import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries"; import { insertEntries } from "./insert/entries";
import { insertShow, updateAvailableCount } from "./insert/shows"; import { insertShow, updateAvailableCount } from "./insert/shows";
@ -80,10 +80,10 @@ export const seedMovie = async (
language: movie.originalLanguage, language: movie.originalLanguage,
name: original.name, name: original.name,
latinName: original.latinName ?? null, latinName: original.latinName ?? null,
poster: processOptImage(original.poster), poster: enqueueOptImage(original.poster),
thumbnail: processOptImage(original.thumbnail), thumbnail: enqueueOptImage(original.thumbnail),
logo: processOptImage(original.logo), logo: enqueueOptImage(original.logo),
banner: processOptImage(original.banner), banner: enqueueOptImage(original.banner),
}, },
...movie, ...movie,
}, },

View File

@ -1,7 +1,7 @@
import { t } from "elysia"; import { t } from "elysia";
import type { SeedSerie } from "~/models/serie"; import type { SeedSerie } from "~/models/serie";
import { getYear } from "~/utils"; import { getYear } from "~/utils";
import { processOptImage } from "./images"; import { enqueueOptImage } from "./images";
import { insertCollection } from "./insert/collection"; import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries"; import { insertEntries } from "./insert/entries";
import { insertSeasons } from "./insert/seasons"; import { insertSeasons } from "./insert/seasons";
@ -115,10 +115,10 @@ export const seedSerie = async (
language: serie.originalLanguage, language: serie.originalLanguage,
name: original.name, name: original.name,
latinName: original.latinName ?? null, latinName: original.latinName ?? null,
poster: processOptImage(original.poster), poster: enqueueOptImage(original.poster),
thumbnail: processOptImage(original.thumbnail), thumbnail: enqueueOptImage(original.thumbnail),
logo: processOptImage(original.logo), logo: enqueueOptImage(original.logo),
banner: processOptImage(original.banner), banner: enqueueOptImage(original.banner),
}, },
...serie, ...serie,
}, },

View File

@ -0,0 +1,23 @@
import {
index,
integer,
jsonb,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { schema } from "./utils";
export const mqueue = schema.table(
"mqueue",
{
id: uuid().notNull().primaryKey().defaultRandom(),
kind: varchar({ length: 255 }).notNull(),
message: jsonb().notNull(),
attempt: integer().notNull().default(0),
createdAt: timestamp({ withTimezone: true, mode: "string" })
.notNull()
.defaultNow(),
},
(t) => [index("mqueue_created").on(t.createdAt)],
);

View File

@ -3,15 +3,23 @@
"target": "ES2021", "target": "ES2021",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "node", "moduleResolution": "node",
"types": ["bun-types"], "types": [
"bun-types"
],
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noErrorTruncation": true, "noErrorTruncation": true,
"resolveJsonModule": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": [
"./src/*"
],
"package.json": [
"package.json"
]
} }
} }
} }