media: webtoon metadata (English title, artist, synopsis, years, adaptation) and stored covers with a cover grid
Deploy production / deploy (push) Successful in 1m24s

This commit is contained in:
stan committed 2026-10-27 09:41:12 +00:00
1 parent 96d7333fe9
commit 3a2d3cc78c
10 files changed
+46302 -22

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
/**
* Store cover images for media.items in media.covers (served by /trackers/media/covers/[id], so
* the page never hotlinks). Idempotent: a re-run replaces the image for the same item.
*
* On stan-ovh (DB_URL is loaded for you, nothing printed):
* scripts/media-run.sh covers ~/imports/webtoon-covers/manifest.json --kind webtoons [--dry-run]
* Manifest: [{ "title": "치즈인더트랩", "file": "720121.jpg", "source_url": "https://…" }], files
* relative to the manifest. Rows are found by the import's match key (kind + normalised title).
*/
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import postgres from 'postgres';
import { matchKey } from '../src/lib/server/media/store';
import { KINDS, MEDIA_KINDS, type MediaKind } from '../src/lib/media/kinds';
const args = process.argv.slice(2);
const i = args.indexOf('--kind');
const kindArg = i >= 0 ? args.splice(i, 2)[1] : 'webtoon';
const dry = args.includes('--dry-run');
const file = args.find((a) => !a.startsWith('--'));
if (!file) throw new Error('usage: media-covers.ts <manifest.json> [--kind webtoons] [--dry-run]');
const kind: MediaKind | undefined =
MEDIA_KINDS.find((k) => k === kindArg) ??
Object.values(KINDS).find((c) => c.name === kindArg)?.kind;
if (!kind) throw new Error(`unknown --kind ${kindArg}`);
const MIME: Record<string, string> = {
ffd8ff: 'image/jpeg',
'89504e': 'image/png',
'524946': 'image/webp',
'474946': 'image/gif'
};
type Entry = { title: string; file: string; source_url?: string };
const list = JSON.parse(await readFile(file, 'utf8')) as Entry[];
const base = dirname(resolve(file));
const items = [];
for (const e of list) {
const bytes = await readFile(resolve(base, e.file));
const mime = MIME[bytes.subarray(0, 3).toString('hex')];
if (!mime || bytes.length > 2_000_000) throw new Error(`${e.title}: not an image or too big`);
items.push({ ...e, bytes, mime });
}
console.log(`read ${items.length} covers`);
if (dry) process.exit(0);
if (!process.env.DB_URL) throw new Error('DB_URL is required (use scripts/media-run.sh covers …)');
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
let stored = 0;
const missing: string[] = [];
try {
await sql.begin(async (tx) => {
for (const c of items) {
const [row] =
await tx`select id from media.items where match_key = ${matchKey(kind, c.title)}`;
if (!row) {
missing.push(c.title);
continue;
}
await tx`insert into media.covers (item_id, mime, bytes, source_url)
values (${row.id}, ${c.mime}, ${c.bytes}, ${c.source_url ?? null})
on conflict (item_id) do update set mime = excluded.mime, bytes = excluded.bytes,
source_url = excluded.source_url, updated_at = now()`;
stored++;
}
});
} finally {
await sql.end();
}
console.log(
`stored ${stored} covers${missing.length ? `; no item for: ${missing.join(', ')}` : ''}`
);