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(', ')}` : ''}`
);
+1 -1
View File
@@ -14,7 +14,7 @@
* title, creator | author | host | channel, platform, genre, status, progress | episode | chapter,
* total | total_episodes, series_status (ongoing|completed, webtoons), form (channel|video|playlist,
* videos), url, cover_url (https), rating (0.5–5), who (stan|juhee|both), started, finished
* (YYYY-MM-DD), notes.
* (YYYY-MM-DD), notes, title_en, artist, synopsis, year_start, year_end, adaptation, source_ref.
* Statuses: webtoons reading|completed|on_hold|dropped|plan; podcasts listening|caught_up|paused|
* dropped|plan; videos watching|following|watched|dropped|plan ("plan to read" etc. map to plan).
*/
+2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Run scripts/media-import.ts on stan-ovh against prod, without printing secrets.
# scripts/media-run.sh ~/imports/webtoons.json --kind webtoons [--dry-run]
# scripts/media-run.sh covers ~/imports/covers/manifest.json --kind webtoons (scripts/media-covers.ts)
# DB_URL (app role) comes from the running app container (db:5432 → 127.0.0.1:5440).
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -8,4 +9,5 @@ DB_URL=$(sudo -n docker inspect personal-website-app-1 --format '{{range .Config
| sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
[ -n "$DB_URL" ] || { echo "no DB_URL from the app container" >&2; exit 1; }
export DB_URL
if [ "${1:-}" = covers ]; then shift; exec bun scripts/media-covers.ts "$@"; fi
exec bun scripts/media-import.ts "$@"