media: webtoon metadata (English title, artist, synopsis, years, adaptation) and stored covers with a cover grid
Deploy production / deploy (push) Successful in 1m24s
Deploy production / deploy (push) Successful in 1m24s
This commit is contained in:
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
@@ -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(', ')}` : ''}`
|
||||||
|
);
|
||||||
@@ -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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in new issue
Block a user