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

@@ -0,0 +1,19 @@
CREATE TABLE "media"."covers" (
"item_id" integer PRIMARY KEY,
"mime" text NOT NULL,
"bytes" bytea NOT NULL,
"source_url" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "covers_mime_check" CHECK ("mime" IN ('image/jpeg','image/png','image/webp','image/gif')),
CONSTRAINT "covers_size_check" CHECK (octet_length("bytes") BETWEEN 1 AND 2000000)
);
--> statement-breakpoint
ALTER TABLE "media"."items" ADD COLUMN "title_en" text;--> statement-breakpoint
ALTER TABLE "media"."items" ADD COLUMN "artist" text;--> statement-breakpoint
ALTER TABLE "media"."items" ADD COLUMN "synopsis" text;--> statement-breakpoint
ALTER TABLE "media"."items" ADD COLUMN "year_start" integer;--> statement-breakpoint
ALTER TABLE "media"."items" ADD COLUMN "year_end" integer;--> statement-breakpoint
ALTER TABLE "media"."items" ADD COLUMN "adaptation" text;--> statement-breakpoint
ALTER TABLE "media"."items" ADD COLUMN "source_ref" text;--> statement-breakpoint
ALTER TABLE "media"."covers" ADD CONSTRAINT "covers_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "media"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "media"."items" ADD CONSTRAINT "items_years_check" CHECK (("year_start" IS NULL OR "year_start" BETWEEN 1900 AND 2100) AND ("year_end" IS NULL OR "year_end" BETWEEN 1900 AND 2100));
File diff suppressed because it is too large. Load diff
+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, * title, creator | author | host | channel, platform, genre, status, progress | episode | chapter,
* total | total_episodes, series_status (ongoing|completed, webtoons), form (channel|video|playlist, * 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 * 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| * 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). * 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 #!/usr/bin/env bash
# Run scripts/media-import.ts on stan-ovh against prod, without printing secrets. # 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 ~/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). # DB_URL (app role) comes from the running app container (db:5432 → 127.0.0.1:5440).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." 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/') | 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; } [ -n "$DB_URL" ] || { echo "no DB_URL from the app container" >&2; exit 1; }
export DB_URL export DB_URL
if [ "${1:-}" = covers ]; then shift; exec bun scripts/media-covers.ts "$@"; fi
exec bun scripts/media-import.ts "$@" exec bun scripts/media-import.ts "$@"
+258 -5
View File
@@ -13,6 +13,11 @@
import Star from 'lucide-svelte/icons/star'; import Star from 'lucide-svelte/icons/star';
import Trash2 from 'lucide-svelte/icons/trash-2'; import Trash2 from 'lucide-svelte/icons/trash-2';
import ExternalLink from 'lucide-svelte/icons/external-link'; import ExternalLink from 'lucide-svelte/icons/external-link';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import ListIcon from 'lucide-svelte/icons/list';
import Tv from 'lucide-svelte/icons/tv';
import CalendarRange from 'lucide-svelte/icons/calendar-range';
import Tag from 'lucide-svelte/icons/tag';
import { KINDS, MEDIA_WHO, SERIES_STATUSES, VIDEO_FORMS, phaseOf, statusOf } from './kinds'; import { KINDS, MEDIA_WHO, SERIES_STATUSES, VIDEO_FORMS, phaseOf, statusOf } from './kinds';
import type { MediaKind } from './kinds'; import type { MediaKind } from './kinds';
import type { MediaItem } from '#lib/server/media/site.js'; import type { MediaItem } from '#lib/server/media/site.js';
@@ -29,7 +34,16 @@
let status = $state('all'); let status = $state('all');
let who = $state<'all' | (typeof MEDIA_WHO)[number]>('all'); let who = $state<'all' | (typeof MEDIA_WHO)[number]>('all');
let sort = $state<'recent' | 'rating' | 'title'>('recent'); let sort = $state<'recent' | 'rating' | 'title' | 'year'>('recent');
// Cover grid by default when the list has covers (webtoons); rows otherwise.
let viewPick = $state<'grid' | 'list' | null>(null);
const view = $derived(
viewPick ??
(data.items.length && data.items.filter((t) => t.coverUrl).length * 2 >= data.items.length
? 'grid'
: 'list')
);
const setView = (v: 'grid' | 'list') => (viewPick = v);
let query = $state(''); let query = $state('');
const counts = $derived.by(() => { const counts = $derived.by(() => {
@@ -48,12 +62,15 @@
(who === 'all' || t.who === who || (who !== 'both' && t.who === 'both')) && (who === 'all' || t.who === who || (who !== 'both' && t.who === 'both')) &&
(!q || (!q ||
t.title.toLowerCase().includes(q) || t.title.toLowerCase().includes(q) ||
(t.titleEn ?? '').toLowerCase().includes(q) ||
(t.artist ?? '').toLowerCase().includes(q) ||
(t.creator ?? '').toLowerCase().includes(q) || (t.creator ?? '').toLowerCase().includes(q) ||
(t.genre ?? '').toLowerCase().includes(q) || (t.genre ?? '').toLowerCase().includes(q) ||
(t.platform ?? '').toLowerCase().includes(q)) (t.platform ?? '').toLowerCase().includes(q))
); );
if (sort === 'rating') return list.toSorted((a, b) => (b.rating ?? -1) - (a.rating ?? -1)); if (sort === 'rating') return list.toSorted((a, b) => (b.rating ?? -1) - (a.rating ?? -1));
if (sort === 'title') return list.toSorted((a, b) => a.title.localeCompare(b.title)); if (sort === 'title') return list.toSorted((a, b) => a.title.localeCompare(b.title));
if (sort === 'year') return list.toSorted((a, b) => (b.yearStart ?? -1) - (a.yearStart ?? -1));
return list; return list;
}); });
@@ -86,6 +103,18 @@
const pct = (t: MediaItem) => const pct = (t: MediaItem) =>
t.progress != null && t.total ? Math.min(1, t.progress / t.total) : null; t.progress != null && t.total ? Math.min(1, t.progress / t.total) : null;
const statusLabel = (s: string) => statusOf(data.kind, s)?.label ?? s; const statusLabel = (s: string) => statusOf(data.kind, s)?.label ?? s;
const years = (t: MediaItem) =>
t.yearStart
? t.yearEnd && t.yearEnd !== t.yearStart
? `${t.yearStart}–${t.yearEnd}`
: t.seriesStatus === 'ongoing'
? `${t.yearStart}–`
: String(t.yearStart)
: null;
const people = (t: MediaItem) =>
t.artist && t.artist !== t.creator
? [t.creator, t.artist].filter(Boolean).join(' / ')
: t.creator;
// ---- dialogs // ---- dialogs
let selectedId = $state<number | null>(null); let selectedId = $state<number | null>(null);
@@ -161,8 +190,16 @@
<label class="full" <label class="full"
>title <input name="title" required maxlength="300" value={t?.title ?? ''} /></label >title <input name="title" required maxlength="300" value={t?.title ?? ''} /></label
> >
{#if cfg.kind === 'webtoon'}
<label class="full"
>English title <input name="title_en" maxlength="300" value={t?.titleEn ?? ''} /></label
>
{/if}
<label>{cfg.creatorLabel} <input name="creator" maxlength="200" value={t?.creator ?? ''} /></label <label>{cfg.creatorLabel} <input name="creator" maxlength="200" value={t?.creator ?? ''} /></label
> >
{#if cfg.kind === 'webtoon'}
<label>artist <input name="artist" maxlength="200" value={t?.artist ?? ''} /></label>
{/if}
<label <label
>platform >platform
<input name="platform" maxlength="60" list="platforms-{cfg.kind}" value={t?.platform ?? ''} /> <input name="platform" maxlength="60" list="platforms-{cfg.kind}" value={t?.platform ?? ''} />
@@ -212,6 +249,23 @@
{#each MEDIA_WHO as w (w)}<option value={w}>{whoLabel[w]}</option>{/each} {#each MEDIA_WHO as w (w)}<option value={w}>{whoLabel[w]}</option>{/each}
</select> </select>
</label> </label>
{#if cfg.kind === 'webtoon'}
<label
>first year
<input name="year_start" type="number" min="1900" max="2100" value={t?.yearStart ?? ''} />
</label>
<label
>last year
<input name="year_end" type="number" min="1900" max="2100" value={t?.yearEnd ?? ''} />
</label>
<label class="full"
>adaptation <input name="adaptation" maxlength="300" value={t?.adaptation ?? ''} /></label
>
<label class="full"
>synopsis <textarea name="synopsis" rows="2" maxlength="2000" value={t?.synopsis ?? ''}
></textarea>
</label>
{/if}
<label>started <input name="started" type="date" value={t?.startedOn ?? ''} /></label> <label>started <input name="started" type="date" value={t?.startedOn ?? ''} /></label>
<label>finished <input name="finished" type="date" value={t?.finishedOn ?? ''} /></label> <label>finished <input name="finished" type="date" value={t?.finishedOn ?? ''} /></label>
<label class="full" <label class="full"
@@ -219,7 +273,13 @@
> >
<label class="full" <label class="full"
>cover image (https) >cover image (https)
<input name="cover_url" type="url" maxlength="1000" value={t?.coverUrl ?? ''} /></label <input
name="cover_url"
type="url"
maxlength="1000"
value={t?.storedCover ? '' : (t?.coverUrl ?? '')}
placeholder={t?.storedCover ? 'stored cover in use' : ''}
/></label
> >
<label class="full" <label class="full"
>notes <textarea name="notes" rows="2" maxlength="4000" value={t?.notes ?? ''}></textarea> >notes <textarea name="notes" rows="2" maxlength="4000" value={t?.notes ?? ''}></textarea>
@@ -307,11 +367,55 @@
<option value="recent">recent</option> <option value="recent">recent</option>
<option value="rating">rating</option> <option value="rating">rating</option>
<option value="title">title</option> <option value="title">title</option>
<option value="year">year</option>
</select> </select>
<div class="views" role="group" aria-label="View">
<button
type="button"
class={{ on: view === 'grid' }}
aria-pressed={view === 'grid'}
title="Cover grid"
onclick={() => setView('grid')}><LayoutGrid size={15} /></button
>
<button
type="button"
class={{ on: view === 'list' }}
aria-pressed={view === 'list'}
title="Rows"
onclick={() => setView('list')}><ListIcon size={15} /></button
>
</div>
</div> </div>
</div> </div>
{#if filtered.length} {#if filtered.length && view === 'grid'}
<ul class="grid">
{#each filtered as t (t.id)}
<li>
<button type="button" class="card" onclick={() => open(t.id)}>
<span class="poster" style:--h={hue(t.title)}>
{#if t.coverUrl}<img
src={t.coverUrl}
alt=""
loading="lazy"
decoding="async"
/>{:else}<span>{initials(t.titleEn ?? t.title)}</span>{/if}
{#if t.rating}<span class="badge"
><Star size={10} fill="currentColor" />{t.rating}</span
>{/if}
{#if t.adaptation}<span class="badge adapt" title={t.adaptation}
><Tv size={10} /></span
>{/if}
</span>
<span class="card-title">{t.title}</span>
{#if t.titleEn}<span class="card-sub">{t.titleEn}</span>{:else if t.creator}<span
class="card-sub">{t.creator}</span
>{/if}
</button>
</li>
{/each}
</ul>
{:else if filtered.length}
<ul class="list"> <ul class="list">
{#each filtered as t (t.id)} {#each filtered as t (t.id)}
{@const p = pct(t)} {@const p = pct(t)}
@@ -324,6 +428,7 @@
</span> </span>
<span class="item-text"> <span class="item-text">
<span class="item-title">{t.title}</span> <span class="item-title">{t.title}</span>
{#if t.titleEn}<span class="item-sub en">{t.titleEn}</span>{/if}
<span class="item-sub"> <span class="item-sub">
{[t.creator, t.platform, t.form && t.form !== 'channel' ? t.form : null] {[t.creator, t.platform, t.form && t.form !== 'channel' ? t.form : null]
.filter(Boolean) .filter(Boolean)
@@ -372,9 +477,21 @@
</span> </span>
<div class="d-info"> <div class="d-info">
<h2>{selected.title}</h2> <h2>{selected.title}</h2>
{#if selected.titleEn}<p class="d-en">{selected.titleEn}</p>{/if}
<p class="item-sub"> <p class="item-sub">
{[selected.creator, selected.platform, selected.genre].filter(Boolean).join(' · ')} {[people(selected), selected.platform].filter(Boolean).join(' · ')}
</p> </p>
{#if selected.genre || years(selected)}
<p class="d-facts">
{#if years(selected)}<span
><CalendarRange size={12} class="ic-cal" />{years(selected)}</span
>{/if}
{#if selected.genre}<span><Tag size={12} class="ic-tag" />{selected.genre}</span>{/if}
</p>
{/if}
{#if selected.adaptation}
<p class="d-facts"><span><Tv size={12} class="ic-tv" />{selected.adaptation}</span></p>
{/if}
<p class="item-meta"> <p class="item-meta">
<span class="pill ph-{phaseOf(data.kind, selected.status)}" <span class="pill ph-{phaseOf(data.kind, selected.status)}"
>{statusLabel(selected.status)}</span >{statusLabel(selected.status)}</span
@@ -407,6 +524,7 @@
><X size={18} /></button ><X size={18} /></button
> >
</div> </div>
{#if selected.synopsis}<p class="d-synopsis">{selected.synopsis}</p>{/if}
{#if selected.notes}<p class="d-notes">{selected.notes}</p>{/if} {#if selected.notes}<p class="d-notes">{selected.notes}</p>{/if}
{#if data.owner} {#if data.owner}
<form <form
@@ -688,6 +806,140 @@
background: transparent; background: transparent;
outline: none; outline: none;
} }
.views {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
overflow: hidden;
}
.views button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
color: var(--site-muted);
cursor: pointer;
}
.views button.on {
background: var(--site-text);
color: var(--site-bg);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7.25rem, 1fr));
gap: 0.9rem 0.7rem;
}
@media (max-width: 540px) {
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 0.5rem;
}
}
.card {
display: flex;
flex-direction: column;
gap: 0.3rem;
width: 100%;
text-align: left;
cursor: pointer;
}
.poster {
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 3 / 4;
border-radius: 0.7rem;
overflow: hidden;
border: 1px solid var(--site-border);
color: white;
font-weight: 700;
font-size: 1.3rem;
background: linear-gradient(150deg, hsl(var(--h) 70% 50%), hsl(calc(var(--h) + 40) 65% 25%));
transition:
transform 0.15s ease,
border-color 0.15s ease;
}
.card:hover .poster {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--site-text) 35%, transparent);
}
.poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.badge {
position: absolute;
top: 0.35rem;
right: 0.35rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0.1rem 0.35rem;
border-radius: 999px;
background: rgb(0 0 0 / 0.72);
backdrop-filter: blur(4px);
color: #fbbf24;
font-size: 0.68rem;
font-weight: 700;
}
.badge.adapt {
right: auto;
left: 0.35rem;
color: #38bdf8;
padding: 0.15rem 0.3rem;
}
.card-title {
font-size: 0.8rem;
font-weight: 650;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-sub {
font-size: 0.7rem;
color: var(--site-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-sub.en {
font-style: italic;
}
.d-en {
margin-top: -0.25rem;
font-size: 0.9rem;
color: var(--site-muted);
}
.d-facts {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
font-size: 0.78rem;
}
.d-facts span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.d-facts :global(.ic-cal) {
color: #a78bfa;
}
.d-facts :global(.ic-tag) {
color: #34d399;
}
.d-facts :global(.ic-tv) {
color: #38bdf8;
}
.d-synopsis {
font-size: 0.88rem;
line-height: 1.45;
margin-bottom: 0.5rem;
}
.list { .list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
@@ -978,7 +1230,8 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.tile, .tile,
.item { .item,
.poster {
transition: none; transition: none;
} }
.tile:hover, .tile:hover,
+53 -3
View File
@@ -1,7 +1,18 @@
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { check, date, index, integer, real, text, uniqueIndex } from 'drizzle-orm/pg-core'; import {
check,
customType,
date,
index,
integer,
real,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { mediaSchema } from './schemas'; import { mediaSchema } from './schemas';
import { id, timestamps } from './helpers'; import { id, timestamps, timestamptz } from './helpers';
const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' });
// Media tracker children that have no tracker of their own (/trackers/webtoons, /podcasts, // Media tracker children that have no tracker of their own (/trackers/webtoons, /podcasts,
// /videos): one row per series, show or channel, typed by `kind` (#lib/media/kinds has the // /videos): one row per series, show or channel, typed by `kind` (#lib/media/kinds has the
@@ -15,8 +26,12 @@ export const mediaItems = mediaSchema.table(
/** `kind:normalised title` (+ `:creator` for videos), the import's dedupe key. */ /** `kind:normalised title` (+ `:creator` for videos), the import's dedupe key. */
matchKey: text('match_key').notNull(), matchKey: text('match_key').notNull(),
title: text('title').notNull(), title: text('title').notNull(),
/** English (or romanised) title when the original is e.g. Korean. */
titleEn: text('title_en'),
/** Author (webtoons), host (podcasts) or channel / creator (videos). */ /** Author (webtoons), host (podcasts) or channel / creator (videos). */
creator: text('creator'), creator: text('creator'),
/** Artist when it differs from the author (webtoons). */
artist: text('artist'),
platform: text('platform'), platform: text('platform'),
genre: text('genre'), genre: text('genre'),
status: text('status').notNull(), status: text('status').notNull(),
@@ -34,6 +49,14 @@ export const mediaItems = mediaSchema.table(
startedOn: date('started_on'), startedOn: date('started_on'),
finishedOn: date('finished_on'), finishedOn: date('finished_on'),
notes: text('notes'), notes: text('notes'),
/** Short blurb (1-2 sentences) from the series' source page. */
synopsis: text('synopsis'),
yearStart: integer('year_start'),
yearEnd: integer('year_end'),
/** Adaptations, e.g. "K-drama (2016)" or "Anime (2024)". */
adaptation: text('adaptation'),
/** Where the metadata came from, e.g. "naver:720121 mangaupdates:akcevst". */
sourceRef: text('source_ref'),
...timestamps ...timestamps
}, },
(t) => [ (t) => [
@@ -65,6 +88,33 @@ export const mediaItems = mediaSchema.table(
sql`(${t.progress} IS NULL OR ${t.progress} >= 0) AND (${t.total} IS NULL OR ${t.total} >= 0)` sql`(${t.progress} IS NULL OR ${t.progress} >= 0) AND (${t.total} IS NULL OR ${t.total} >= 0)`
), ),
check('items_url_check', sql`${t.url} IS NULL OR ${t.url} ~ '^https?://'`), check('items_url_check', sql`${t.url} IS NULL OR ${t.url} ~ '^https?://'`),
check('items_cover_check', sql`${t.coverUrl} IS NULL OR ${t.coverUrl} ~ '^https://'`) check('items_cover_check', sql`${t.coverUrl} IS NULL OR ${t.coverUrl} ~ '^https://'`),
check(
'items_years_check',
sql`(${t.yearStart} IS NULL OR ${t.yearStart} BETWEEN 1900 AND 2100) AND (${t.yearEnd} IS NULL OR ${t.yearEnd} BETWEEN 1900 AND 2100)`
)
]
);
// Cover images stored on our side (never hotlinked), served by /trackers/media/covers/[id].
// One per item; bytes live in the DB so the cover follows the row's backups.
export const mediaCovers = mediaSchema.table(
'covers',
{
itemId: integer('item_id')
.primaryKey()
.references(() => mediaItems.id, { onDelete: 'cascade' }),
mime: text('mime').notNull(),
bytes: bytea('bytes').notNull(),
/** Where the image was downloaded from (provenance only, never rendered). */
sourceUrl: text('source_url'),
updatedAt: timestamptz('updated_at').notNull().defaultNow()
},
(t) => [
check(
'covers_mime_check',
sql`${t.mime} IN ('image/jpeg','image/png','image/webp','image/gif')`
),
check('covers_size_check', sql`octet_length(${t.bytes}) BETWEEN 1 AND 2000000`)
] ]
); );
+38 -9
View File
@@ -23,6 +23,14 @@ export type MediaItem = {
startedOn: string | null; startedOn: string | null;
finishedOn: string | null; finishedOn: string | null;
notes: string | null; notes: string | null;
titleEn: string | null;
artist: string | null;
synopsis: string | null;
yearStart: number | null;
yearEnd: number | null;
adaptation: string | null;
/** True when coverUrl points at our own stored cover (/trackers/media/covers/[id]). */
storedCover: boolean;
updatedAt: string; updatedAt: string;
}; };
@@ -39,23 +47,36 @@ const toItem = (r: Record<string, unknown>): MediaItem => ({
seriesStatus: (r.series_status as string) ?? null, seriesStatus: (r.series_status as string) ?? null,
form: (r.form as string) ?? null, form: (r.form as string) ?? null,
url: (r.url as string) ?? null, url: (r.url as string) ?? null,
coverUrl: (r.cover_url as string) ?? null, coverUrl: r.cover_v
? `/trackers/media/covers/${r.id}?v=${r.cover_v}`
: ((r.cover_url as string) ?? null),
rating: (r.rating as number) ?? null, rating: (r.rating as number) ?? null,
who: (r.who as string) ?? null, who: (r.who as string) ?? null,
startedOn: (r.started_on as string) ?? null, startedOn: (r.started_on as string) ?? null,
finishedOn: (r.finished_on as string) ?? null, finishedOn: (r.finished_on as string) ?? null,
notes: (r.notes as string) ?? null, notes: (r.notes as string) ?? null,
titleEn: (r.title_en as string) ?? null,
artist: (r.artist as string) ?? null,
synopsis: (r.synopsis as string) ?? null,
yearStart: (r.year_start as number) ?? null,
yearEnd: (r.year_end as number) ?? null,
adaptation: (r.adaptation as string) ?? null,
storedCover: !!r.cover_v,
updatedAt: new Date(r.updated_at as string).toISOString() updatedAt: new Date(r.updated_at as string).toISOString()
}); });
const columns = sql`id, kind, title, creator, platform, genre, status, progress, total, series_status, form, const columns = sql`i.id, i.kind, i.title, i.creator, i.platform, i.genre, i.status, i.progress, i.total,
url, cover_url, rating, who, started_on::text, finished_on::text, notes, coalesce(updated_at, created_at) as updated_at`; i.series_status, i.form, i.url, i.cover_url, i.rating, i.who, i.started_on::text, i.finished_on::text,
i.notes, i.title_en, i.artist, i.synopsis, i.year_start, i.year_end, i.adaptation,
extract(epoch from c.updated_at)::bigint::text as cover_v,
coalesce(i.updated_at, i.created_at) as updated_at`;
const from = sql`media.items i left join media.covers c on c.item_id = i.id`;
/** All items of one kind, most recently touched first. Empty (not an error) before the migration. */ /** All items of one kind, most recently touched first. Empty (not an error) before the migration. */
export async function loadItems(kind: MediaKind): Promise<MediaItem[]> { export async function loadItems(kind: MediaKind): Promise<MediaItem[]> {
try { try {
const rows = await sql`select ${columns} from media.items where kind = ${kind} const rows = await sql`select ${columns} from ${from} where i.kind = ${kind}
order by coalesce(updated_at, created_at) desc, id desc`; order by coalesce(i.updated_at, i.created_at) desc, i.id desc`;
return rows.map(toItem); return rows.map(toItem);
} catch { } catch {
return []; return [];
@@ -65,8 +86,8 @@ export async function loadItems(kind: MediaKind): Promise<MediaItem[]> {
/** Every item (the hub). */ /** Every item (the hub). */
export async function loadAllItems(): Promise<MediaItem[]> { export async function loadAllItems(): Promise<MediaItem[]> {
try { try {
const rows = await sql`select ${columns} from media.items const rows = await sql`select ${columns} from ${from}
order by coalesce(updated_at, created_at) desc, id desc`; order by coalesce(i.updated_at, i.created_at) desc, i.id desc`;
return rows.map(toItem); return rows.map(toItem);
} catch { } catch {
return []; return [];
@@ -94,7 +115,13 @@ const formEntry = (form: FormData) => ({
who: get(form, 'who'), who: get(form, 'who'),
started: get(form, 'started'), started: get(form, 'started'),
finished: get(form, 'finished'), finished: get(form, 'finished'),
notes: get(form, 'notes') notes: get(form, 'notes'),
title_en: get(form, 'title_en'),
artist: get(form, 'artist'),
synopsis: get(form, 'synopsis'),
year_start: get(form, 'year_start'),
year_end: get(form, 'year_end'),
adaptation: get(form, 'adaptation')
}); });
const today = () => const today = () =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Amsterdam' }).format(new Date()); new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Amsterdam' }).format(new Date());
@@ -141,7 +168,9 @@ export async function updateItem(kind: MediaKind, form: FormData): Promise<Resul
platform = ${v.platform}, genre = ${v.genre}, status = ${status}, progress = ${v.progress}, platform = ${v.platform}, genre = ${v.genre}, status = ${status}, progress = ${v.progress},
total = ${v.total}, series_status = ${v.seriesStatus}, form = ${v.form}, url = ${v.url}, total = ${v.total}, series_status = ${v.seriesStatus}, form = ${v.form}, url = ${v.url},
cover_url = ${v.coverUrl}, rating = ${v.rating}, who = ${v.who}, started_on = ${v.startedOn}, cover_url = ${v.coverUrl}, rating = ${v.rating}, who = ${v.who}, started_on = ${v.startedOn},
finished_on = ${v.finishedOn}, notes = ${v.notes}, updated_at = now() finished_on = ${v.finishedOn}, notes = ${v.notes}, title_en = ${v.titleEn}, artist = ${v.artist},
synopsis = ${v.synopsis}, year_start = ${v.yearStart}, year_end = ${v.yearEnd},
adaptation = ${v.adaptation}, updated_at = now()
where id = ${id} and kind = ${kind}`; where id = ${id} and kind = ${kind}`;
} catch (e) { } catch (e) {
if ((e as { code?: string }).code === '23505') if ((e as { code?: string }).code === '23505')
+35 -4
View File
@@ -87,7 +87,15 @@ export const importEntry = z.object({
who: opt(z.enum(MEDIA_WHO)), who: opt(z.enum(MEDIA_WHO)),
started: opt(date), started: opt(date),
finished: opt(date), finished: opt(date),
notes: opt(text(4000)) notes: opt(text(4000)),
title_en: opt(text(300)),
english_title: opt(text(300)),
artist: opt(text(200)),
synopsis: opt(text(2000)),
year_start: opt(z.coerce.number().int().min(1900).max(2100)),
year_end: opt(z.coerce.number().int().min(1900).max(2100)),
adaptation: opt(text(300)),
source_ref: opt(text(300))
}); });
export type ImportEntry = z.infer<typeof importEntry>; export type ImportEntry = z.infer<typeof importEntry>;
@@ -109,6 +117,13 @@ export type MediaRow = {
startedOn: string | null; startedOn: string | null;
finishedOn: string | null; finishedOn: string | null;
notes: string | null; notes: string | null;
titleEn: string | null;
artist: string | null;
synopsis: string | null;
yearStart: number | null;
yearEnd: number | null;
adaptation: string | null;
sourceRef: string | null;
}; };
/** Validate one entry for a kind: aliases folded, status checked against the kind's list. */ /** Validate one entry for a kind: aliases folded, status checked against the kind's list. */
@@ -147,7 +162,14 @@ export function toRow(
who: v.who ?? null, who: v.who ?? null,
startedOn: v.started ?? null, startedOn: v.started ?? null,
finishedOn: v.finished ?? null, finishedOn: v.finished ?? null,
notes: v.notes ?? null notes: v.notes ?? null,
titleEn: v.title_en ?? v.english_title ?? null,
artist: v.artist ?? null,
synopsis: v.synopsis ?? null,
yearStart: v.year_start ?? null,
yearEnd: v.year_end ?? null,
adaptation: v.adaptation ?? null,
sourceRef: v.source_ref ?? null
} }
}; };
} }
@@ -197,10 +219,12 @@ export async function upsertRow(tx: Tx, r: MediaRow): Promise<{ id: number; crea
const key = matchKey(r.kind, r.title, r.creator); const key = matchKey(r.kind, r.title, r.creator);
const [row] = await tx` const [row] = await tx`
insert into media.items (kind, match_key, title, creator, platform, genre, status, progress, total, insert into media.items (kind, match_key, title, creator, platform, genre, status, progress, total,
series_status, form, url, cover_url, rating, who, started_on, finished_on, notes) series_status, form, url, cover_url, rating, who, started_on, finished_on, notes,
title_en, artist, synopsis, year_start, year_end, adaptation, source_ref)
values (${r.kind}, ${key}, ${r.title}, ${r.creator}, ${r.platform}, ${r.genre}, ${r.status ?? 'plan'}, values (${r.kind}, ${key}, ${r.title}, ${r.creator}, ${r.platform}, ${r.genre}, ${r.status ?? 'plan'},
${r.progress}, ${r.total}, ${r.seriesStatus}, ${r.form}, ${r.url}, ${r.coverUrl}, ${r.rating}, ${r.progress}, ${r.total}, ${r.seriesStatus}, ${r.form}, ${r.url}, ${r.coverUrl}, ${r.rating},
${r.who}, ${r.startedOn}, ${r.finishedOn}, ${r.notes}) ${r.who}, ${r.startedOn}, ${r.finishedOn}, ${r.notes}, ${r.titleEn}, ${r.artist},
${r.synopsis}, ${r.yearStart}, ${r.yearEnd}, ${r.adaptation}, ${r.sourceRef})
on conflict (match_key) do update set on conflict (match_key) do update set
title = excluded.title, title = excluded.title,
creator = coalesce(excluded.creator, media.items.creator), creator = coalesce(excluded.creator, media.items.creator),
@@ -218,6 +242,13 @@ export async function upsertRow(tx: Tx, r: MediaRow): Promise<{ id: number; crea
started_on = coalesce(excluded.started_on, media.items.started_on), started_on = coalesce(excluded.started_on, media.items.started_on),
finished_on = coalesce(excluded.finished_on, media.items.finished_on), finished_on = coalesce(excluded.finished_on, media.items.finished_on),
notes = coalesce(excluded.notes, media.items.notes), notes = coalesce(excluded.notes, media.items.notes),
title_en = coalesce(excluded.title_en, media.items.title_en),
artist = coalesce(excluded.artist, media.items.artist),
synopsis = coalesce(excluded.synopsis, media.items.synopsis),
year_start = coalesce(excluded.year_start, media.items.year_start),
year_end = coalesce(excluded.year_end, media.items.year_end),
adaptation = coalesce(excluded.adaptation, media.items.adaptation),
source_ref = coalesce(excluded.source_ref, media.items.source_ref),
updated_at = now() updated_at = now()
returning id, (xmax = 0) as created`; returning id, (xmax = 0) as created`;
return { id: row.id, created: row.created }; return { id: row.id, created: row.created };
@@ -0,0 +1,23 @@
// Public cover images for media.items (webtoons, podcasts, videos), stored in media.covers so
// the page never hotlinks a third-party CDN. Immutable per ?v= (the cover's updated_at).
import { error } from '@sveltejs/kit';
import { client as sql } from '#lib/server/db/index.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url }) => {
const [row] =
await sql`select mime, bytes from media.covers where item_id = ${Number(params.id)}`;
if (!row) error(404, 'Not Found');
const body = new Uint8Array(row.bytes as Buffer);
return new Response(body, {
headers: {
'content-type': row.mime as string,
'content-length': String(body.byteLength),
'cache-control': url.searchParams.has('v')
? 'public, max-age=31536000, immutable'
: 'public, max-age=86400, stale-while-revalidate=604800',
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox"
}
});
};