Use reworked image queue in every insert

This commit is contained in:
Zoe Roux 2025-03-16 17:58:13 +01:00
parent 71b8cbca4a
commit 9ef114b91a
No known key found for this signature in database
11 changed files with 218 additions and 147 deletions

View File

@ -2,10 +2,11 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { encode } from "blurhash";
import { eq, sql } from "drizzle-orm";
import type { PgColumn } from "drizzle-orm/pg-core";
import { version } from "package.json";
import type { PoolClient } from "pg";
import sharp from "sharp";
import { db } from "~/db";
import { type Transaction, db } from "~/db";
import * as schema from "~/db/schema";
import { mqueue } from "~/db/schema/queue";
import type { Image } from "~/models/utils";
@ -20,18 +21,31 @@ type ImageTask = {
column: string;
};
type ImageTaskC = {
url: string;
column: PgColumn;
};
// 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
// requests are not blocked by image downloading or blurhash calculation
export const enqueueImage = async (
tx: typeof db,
img: Omit<ImageTask, "id">,
tx: Transaction,
img: ImageTaskC,
): Promise<Image> => {
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(img.url);
const id = hasher.digest().toString("hex");
await tx.insert(mqueue).values({ kind: "image", message: { id, ...img } });
await tx.insert(mqueue).values({
kind: "image",
message: {
id,
url: img.url,
table: img.column.table._.name,
column: img.column.name,
} satisfies ImageTask,
});
await tx.execute(sql`notify image`);
return {
@ -42,11 +56,11 @@ export const enqueueImage = async (
};
export const enqueueOptImage = async (
tx: typeof db,
img: Omit<ImageTask, "id">,
tx: Transaction,
img: { url: string | null; column: PgColumn },
): Promise<Image | null> => {
if (!img.url) return null;
return await enqueueImage(tx, img);
return await enqueueImage(tx, { url: img.url, column: img.column });
};
export const processImages = async () => {
@ -107,6 +121,7 @@ export const processImages = async () => {
};
async function downloadImage(id: string, url: string): Promise<string> {
// TODO: check if file exists before downloading
const resp = await fetch(url, {
headers: { "User-Agent": `Kyoo v${version}` },
});

View File

@ -48,16 +48,28 @@ export const insertCollection = async (
})
.returning({ pk: shows.pk, id: shows.id, slug: shows.slug });
const trans: ShowTrans[] = Object.entries(translations).map(
([lang, tr]) => ({
const trans: ShowTrans[] = await Promise.all(
Object.entries(translations).map(async ([lang, tr]) => ({
pk: ret.pk,
language: lang,
...tr,
poster: enqueueOptImage(tr.poster),
thumbnail: enqueueOptImage(tr.thumbnail),
logo: enqueueOptImage(tr.logo),
banner: enqueueOptImage(tr.banner),
}),
poster: await enqueueOptImage(tx, {
url: tr.poster,
column: showTranslations.poster,
}),
thumbnail: await enqueueOptImage(tx, {
url: tr.thumbnail,
column: showTranslations.thumbnail,
}),
logo: await enqueueOptImage(tx, {
url: tr.logo,
column: showTranslations.logo,
}),
banner: await enqueueOptImage(tx, {
url: tr.banner,
column: showTranslations.banner,
}),
})),
);
await tx
.insert(showTranslations)

View File

@ -23,6 +23,7 @@ type SeedExtra = Omit<SExtra, "kind"> & {
};
type EntryI = typeof entries.$inferInsert;
type EntryTransI = typeof entryTranslations.$inferInsert;
const generateSlug = (
showSlug: string,
@ -49,25 +50,30 @@ export const insertEntries = async (
if (!items) return [];
const retEntries = await db.transaction(async (tx) => {
const vals: EntryI[] = items.map((seed) => {
const { translations, videos, video, ...entry } = seed;
return {
...entry,
showPk: show.pk,
slug: generateSlug(show.slug, seed),
thumbnail: enqueueOptImage(seed.thumbnail),
nextRefresh:
entry.kind !== "extra"
? guessNextRefresh(entry.airDate ?? new Date())
: guessNextRefresh(new Date()),
episodeNumber:
entry.kind === "episode"
? entry.episodeNumber
: entry.kind === "special"
? entry.number
: undefined,
};
});
const vals: EntryI[] = await Promise.all(
items.map(async (seed) => {
const { translations, videos, video, ...entry } = seed;
return {
...entry,
showPk: show.pk,
slug: generateSlug(show.slug, seed),
thumbnail: await enqueueOptImage(tx, {
url: seed.thumbnail,
column: entries.thumbnail,
}),
nextRefresh:
entry.kind !== "extra"
? guessNextRefresh(entry.airDate ?? new Date())
: guessNextRefresh(new Date()),
episodeNumber:
entry.kind === "episode"
? entry.episodeNumber
: entry.kind === "special"
? entry.number
: undefined,
};
}),
);
const ret = await tx
.insert(entries)
.values(vals)
@ -83,30 +89,41 @@ export const insertEntries = async (
})
.returning({ pk: entries.pk, id: entries.id, slug: entries.slug });
const trans = items.flatMap((seed, i) => {
if (seed.kind === "extra") {
return {
pk: ret[i].pk,
// yeah we hardcode the language to extra because if we want to support
// translations one day it won't be awkward
language: "extra",
name: seed.name,
description: null,
poster: undefined,
};
}
const trans: EntryTransI[] = (
await Promise.all(
items.map(async (seed, i) => {
if (seed.kind === "extra") {
return [
{
pk: ret[i].pk,
// yeah we hardcode the language to extra because if we want to support
// translations one day it won't be awkward
language: "extra",
name: seed.name,
description: null,
poster: undefined,
},
];
}
return Object.entries(seed.translations).map(([lang, tr]) => ({
// assumes ret is ordered like items.
pk: ret[i].pk,
language: lang,
...tr,
poster:
seed.kind === "movie"
? enqueueOptImage((tr as any).poster)
: undefined,
}));
});
return await Promise.all(
Object.entries(seed.translations).map(async ([lang, tr]) => ({
// assumes ret is ordered like items.
pk: ret[i].pk,
language: lang,
...tr,
poster:
seed.kind === "movie"
? await enqueueOptImage(tx, {
url: (tr as any).poster,
column: entryTranslations.poster,
})
: undefined,
})),
);
}),
)
).flat();
await tx
.insert(entryTranslations)
.values(trans)

View File

@ -37,17 +37,33 @@ export const insertSeasons = async (
})
.returning({ pk: seasons.pk, id: seasons.id, slug: seasons.slug });
const trans: SeasonTransI[] = items.flatMap((seed, i) =>
Object.entries(seed.translations).map(([lang, tr]) => ({
// assumes ret is ordered like items.
pk: ret[i].pk,
language: lang,
...tr,
poster: enqueueOptImage(tr.poster),
thumbnail: enqueueOptImage(tr.thumbnail),
banner: enqueueOptImage(tr.banner),
})),
);
const trans: SeasonTransI[] = (
await Promise.all(
items.map(
async (seed, i) =>
await Promise.all(
Object.entries(seed.translations).map(async ([lang, tr]) => ({
// assumes ret is ordered like items.
pk: ret[i].pk,
language: lang,
...tr,
poster: await enqueueOptImage(tx, {
url: tr.poster,
column: seasonTranslations.poster,
}),
thumbnail: await enqueueOptImage(tx, {
url: tr.thumbnail,
column: seasonTranslations.thumbnail,
}),
banner: await enqueueOptImage(tx, {
url: tr.banner,
column: seasonTranslations.banner,
}),
})),
),
),
)
).flat();
await tx
.insert(seasonTranslations)
.values(trans)

View File

@ -1,5 +1,5 @@
import { and, count, eq, exists, ne, sql } from "drizzle-orm";
import { db } from "~/db";
import { type Transaction, db } from "~/db";
import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema";
import { conflictUpdateAllExcept, sqlarr } from "~/db/utils";
import type { SeedCollection } from "~/models/collections";
@ -12,30 +12,53 @@ type Show = typeof shows.$inferInsert;
type ShowTrans = typeof showTranslations.$inferInsert;
export const insertShow = async (
show: Show,
show: Omit<Show, "original"> & { originalLanguage: string },
translations:
| SeedMovie["translations"]
| SeedSerie["translations"]
| SeedCollection["translations"],
) => {
return await db.transaction(async (tx) => {
const ret = await insertBaseShow(tx, show);
const trans: (Omit<ShowTrans, "pk"> & { latinName: string | null })[] =
await Promise.all(
Object.entries(translations).map(async ([lang, tr]) => ({
language: lang,
...tr,
latinName: tr.latinName ?? null,
poster: await enqueueOptImage(tx, {
url: tr.poster,
column: showTranslations.poster,
}),
thumbnail: await enqueueOptImage(tx, {
url: tr.thumbnail,
column: showTranslations.thumbnail,
}),
logo: await enqueueOptImage(tx, {
url: tr.logo,
column: showTranslations.logo,
}),
banner: await enqueueOptImage(tx, {
url: tr.banner,
column: showTranslations.banner,
}),
})),
);
const original = trans.find((x) => x.language === show.originalLanguage);
if (!original) {
tx.rollback();
return {
status: 422 as const,
message: "No translation available in the original language.",
};
}
const ret = await insertBaseShow(tx, { ...show, original });
if ("status" in ret) return ret;
const trans: ShowTrans[] = Object.entries(translations).map(
([lang, tr]) => ({
pk: ret.pk,
language: lang,
...tr,
poster: enqueueOptImage(tr.poster),
thumbnail: enqueueOptImage(tr.thumbnail),
logo: enqueueOptImage(tr.logo),
banner: enqueueOptImage(tr.banner),
}),
);
await tx
.insert(showTranslations)
.values(trans)
.values(trans.map((x) => ({ ...x, pk: ret.pk })))
.onConflictDoUpdate({
target: [showTranslations.pk, showTranslations.language],
set: conflictUpdateAllExcept(showTranslations, ["pk", "language"]),
@ -44,10 +67,7 @@ export const insertShow = async (
});
};
async function insertBaseShow(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
show: Show,
) {
async function insertBaseShow(tx: Transaction, show: Show) {
function insert() {
return tx
.insert(shows)
@ -97,7 +117,7 @@ async function insertBaseShow(
}
export async function updateAvailableCount(
tx: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0],
tx: Transaction,
showPks: number[],
updateEntryCount = true,
) {

View File

@ -12,10 +12,15 @@ export const insertStaff = async (
if (!seed?.length) return [];
return await db.transaction(async (tx) => {
const people = seed.map((x) => ({
...x.staff,
image: enqueueOptImage(x.staff.image),
}));
const people = await Promise.all(
seed.map(async (x) => ({
...x.staff,
image: await enqueueOptImage(tx, {
url: x.staff.image,
column: staff.image,
}),
})),
);
const ret = await tx
.insert(staff)
.values(people)
@ -25,16 +30,21 @@ export const insertStaff = async (
})
.returning({ pk: staff.pk, id: staff.id, slug: staff.slug });
const rval = seed.map((x, i) => ({
showPk,
staffPk: ret[i].pk,
kind: x.kind,
order: i,
character: {
...x.character,
image: enqueueOptImage(x.character.image),
},
}));
const rval = await Promise.all(
seed.map(async (x, i) => ({
showPk,
staffPk: ret[i].pk,
kind: x.kind,
order: i,
character: {
...x.character,
image: await enqueueOptImage(tx, {
url: x.character.image,
column: roles.character.image,
}),
},
})),
);
// always replace all roles. this is because:
// - we want `order` to stay in sync (& without duplicates)

View File

@ -33,14 +33,24 @@ export const insertStudios = async (
})
.returning({ pk: studios.pk, id: studios.id, slug: studios.slug });
const trans: StudioTransI[] = seed.flatMap((x, i) =>
Object.entries(x.translations).map(([lang, tr]) => ({
pk: ret[i].pk,
language: lang,
name: tr.name,
logo: enqueueOptImage(tr.logo),
})),
);
const trans: StudioTransI[] = (
await Promise.all(
seed.map(
async (x, i) =>
await Promise.all(
Object.entries(x.translations).map(async ([lang, tr]) => ({
pk: ret[i].pk,
language: lang,
name: tr.name,
logo: await enqueueOptImage(tx, {
url: tr.logo,
column: studioTranslations.logo,
}),
})),
),
),
)
).flat();
await tx
.insert(studioTranslations)
.values(trans)

View File

@ -1,7 +1,6 @@
import { t } from "elysia";
import type { SeedMovie } from "~/models/movie";
import { getYear } from "~/utils";
import { enqueueOptImage } from "./images";
import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries";
import { insertShow, updateAvailableCount } from "./insert/shows";
@ -55,13 +54,6 @@ export const seedMovie = async (
const { translations, videos, collection, studios, staff, ...movie } = seed;
const nextRefresh = guessNextRefresh(movie.airDate ?? new Date());
const original = translations[movie.originalLanguage];
if (!original) {
return {
status: 422,
message: "No translation available in the original language.",
};
}
const col = await insertCollection(collection, {
kind: "movie",
@ -76,15 +68,6 @@ export const seedMovie = async (
nextRefresh,
collectionPk: col?.pk,
entriesCount: 1,
original: {
language: movie.originalLanguage,
name: original.name,
latinName: original.latinName ?? null,
poster: enqueueOptImage(original.poster),
thumbnail: enqueueOptImage(original.thumbnail),
logo: enqueueOptImage(original.logo),
banner: enqueueOptImage(original.banner),
},
...movie,
},
translations,

View File

@ -5,7 +5,7 @@ import { enqueueOptImage } from "./images";
import { insertCollection } from "./insert/collection";
import { insertEntries } from "./insert/entries";
import { insertSeasons } from "./insert/seasons";
import { insertShow, updateAvailableCount } from "./insert/shows";
import { insertShow } from "./insert/shows";
import { insertStaff } from "./insert/staff";
import { insertStudios } from "./insert/studios";
import { guessNextRefresh } from "./refresh";
@ -91,13 +91,6 @@ export const seedSerie = async (
...serie
} = seed;
const nextRefresh = guessNextRefresh(serie.startAir ?? new Date());
const original = translations[serie.originalLanguage];
if (!original) {
return {
status: 422,
message: "No translation available in the original language.",
};
}
const col = await insertCollection(collection, {
kind: "serie",
@ -111,15 +104,6 @@ export const seedSerie = async (
nextRefresh,
collectionPk: col?.pk,
entriesCount: entries.length,
original: {
language: serie.originalLanguage,
name: original.name,
latinName: original.latinName ?? null,
poster: enqueueOptImage(original.poster),
thumbnail: enqueueOptImage(original.thumbnail),
logo: enqueueOptImage(original.logo),
banner: enqueueOptImage(original.banner),
},
...serie,
},
translations,

View File

@ -31,3 +31,7 @@ export const migrate = async () => {
});
console.log(`Database ${dbConfig.database} migrated!`);
};
export type Transaction =
| typeof db
| Parameters<Parameters<typeof db.transaction>[0]>[0];

View File

@ -58,10 +58,10 @@ export const genres = schema.enum("genres", [
]);
type OriginalWithImages = Original & {
poster: Image | null;
thumbnail: Image | null;
banner: Image | null;
logo: Image | null;
poster?: Image | null;
thumbnail?: Image | null;
banner?: Image | null;
logo?: Image | null;
};
export const shows = schema.table(