diff --git a/migrations/011_per_source_metadata.sql b/migrations/011_per_source_metadata.sql new file mode 100644 index 0000000..617ce9c --- /dev/null +++ b/migrations/011_per_source_metadata.sql @@ -0,0 +1,4 @@ +-- Metadaten-Beileger (.md) pro Upload-Quelle steuerbar statt global. +ALTER TABLE settings ADD COLUMN IF NOT EXISTS picdrop_metadata_sidecar bool NOT NULL DEFAULT false; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_metadata_sidecar bool NOT NULL DEFAULT false; +ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false; diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index cd79766..04bd889 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -37,6 +37,10 @@ export default function AdminApp() { await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, is_backup: !t.is_backup }) }); load(); }; + const toggleTargetSidecar = async (t: any) => { + await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, metadata_sidecar: !t.metadata_sidecar }) }); + load(); + }; const backupAllTargets = async () => { if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return; notify('Sichere … (kann dauern)'); @@ -74,7 +78,7 @@ export default function AdminApp() { default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days, - metadata_sidecar: !!s.metadata_sidecar, + picdrop_metadata_sidecar: !!s.picdrop_metadata_sidecar, nas_metadata_sidecar: !!s.nas_metadata_sidecar, library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations, private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw, nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port, @@ -91,6 +95,12 @@ export default function AdminApp() { const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json()); notify(r.message || (r.ok ? 'OK' : 'Fehler')); }; + const [pdList, setPdList] = useState(null); + const lsPicdrop = async (path?: string) => { + notify('Lese Picdrop-Ordner …'); + const r = await fetch('/api/admin/picdrop-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) }).then((x) => x.json()); + setPdList(r); notify(r.error ? r.error : `${(r.entries || []).length} Einträge in ${r.path}`); + }; const testNas = async () => { notify('Teste NAS …'); const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json()); @@ -109,7 +119,7 @@ export default function AdminApp() { if (confirmMsg && !confirm(confirmMsg)) return; notify('Räume auf …'); const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }).then((x) => x.json()); - notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : (r.error || 'OK')); + notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : r.made != null ? `${r.made} Vorschaubilder erzeugt.` : (r.error || 'OK')); load(); }; const mirrorAll = async () => { @@ -193,7 +203,8 @@ export default function AdminApp() {
Picdrop -
+
+
field('picdrop_host', e.target.value)} />
@@ -211,7 +222,20 @@ export default function AdminApp() {
field('picdrop_base_path', e.target.value)} />
field('picdrop_default_gallery', e.target.value)} />
-
Bilder landen in Basisordner / Galerie — z. B. / + POSTER LEA. Pro Ordner lässt sich später eine eigene Galerie mappen.
+ +
Bilder landen in Basisordner / Galerie — z. B. / + POSTER LEA. „Ordner anzeigen" listet, was per SFTP tatsächlich wo liegt.
+ {pdList && ( +
+
{pdList.path}{pdList.error ? ` — ${pdList.error}` : ''}
+ {(pdList.entries || []).map((e: any, i: number) => ( +
+ + {e.size ? `${Math.round(e.size / 1024)} KB` : ''} +
+ ))} +
+ )}
@@ -223,13 +247,13 @@ export default function AdminApp() { Vorschaubilder erzeugenKleine, sparsame Bilder für die Bibliotheksvorschau. - +
Metadaten-Beileger (.md mit Prompt/Modell/Format) lassen sich jetzt pro Ziel ein-/ausschalten — bei Picdrop, beim NAS und bei jedem FTP-Ziel einzeln (siehe die jeweilige Karte).
field('retention_days', e.target.value)} />
Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.
+ {s.retention_days ? : null}
@@ -255,6 +279,8 @@ export default function AdminApp() {
setNasPw(e.target.value)} />
field('nas_base_path', e.target.value)} />
+
Ergebnisse landen unter Basisordner / JJJJ-MM /. Zugangsdaten werden verschlüsselt gespeichert.
@@ -270,9 +296,10 @@ export default function AdminApp() {
{targets.map((t) => (
-
{t.name}{t.is_backup && Backup} {t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}
+
{t.name}{t.is_backup && Backup}{t.metadata_sidecar && .md} {t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}
+
@@ -297,6 +324,8 @@ export default function AdminApp() {
setNt({ ...nt, base_path: e.target.value })} />
+
@@ -435,5 +464,8 @@ function AdminStyles() { .reihe{display:flex;gap:6px;} .mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);} .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;} + .pdlist{background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:10px;margin-top:8px;max-height:260px;overflow:auto;} + .pdrow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 0;} + .link2{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:12px;text-align:left;padding:0;} `}; } diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx index 3155f48..a9471bb 100644 --- a/src/components/LibraryApp.tsx +++ b/src/components/LibraryApp.tsx @@ -249,10 +249,13 @@ export default function LibraryApp() { )} {saveView && (
setSaveView(null)}> - {saveView.filename e.stopPropagation()} /> +
e.stopPropagation()}> + + {saveView.filename +
e.stopPropagation()}> 📱 iPhone/iPad: lange auf das Bild tippen → „In Fotos sichern". - Als Datei herunterladen + Original als Datei (.png)
@@ -309,7 +312,10 @@ function LibStyles() { .tagmenu button{display:flex;align-items:center;gap:8px;background:none;border:none;text-align:left;padding:7px 9px;cursor:pointer;font-family:inherit;font-size:13px;border-radius:4px;color:var(--ink);} .tagmenu button:hover{background:var(--paper);} .tagmenu .flag{font-size:15px;} + .savewrap{position:relative;max-width:94vw;max-height:78vh;display:flex;} .saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;} + .saveimg.blur{filter:blur(8px);position:absolute;inset:0;width:100%;height:100%;object-fit:contain;} + .saveimg.full{position:relative;z-index:1;} .save-fuss{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;max-width:90vw;} .fassung{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;} .varianten{display:flex;gap:5px;margin-top:6px;overflow-x:auto;} diff --git a/src/components/QueueApp.tsx b/src/components/QueueApp.tsx index ad55e52..47edd04 100644 --- a/src/components/QueueApp.tsx +++ b/src/components/QueueApp.tsx @@ -96,10 +96,10 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
{saveView && (
setSaveView(null)}> - e.stopPropagation()} /> + e.stopPropagation()} />
e.stopPropagation()}> 📱 iPhone/iPad: lange auf das Bild tippen → „In Fotos sichern". - Als Datei herunterladen + Original als Datei (.png)
diff --git a/src/lib/backup.ts b/src/lib/backup.ts index 366e71f..494409c 100644 --- a/src/lib/backup.ts +++ b/src/lib/backup.ts @@ -7,26 +7,24 @@ import { uploadBuffer, loadTargetConfig, type PicdropCfg } from './picdrop'; import { loadNasConfig } from './nas'; import { buildMetadataMd, sidecarName } from './metadata'; -export interface BackupDest { key: string; label: string; cfg: PicdropCfg; } +export interface BackupDest { key: string; label: string; cfg: PicdropCfg; sidecar: boolean; } -/** Alle aktiven Backup-Ziele einsammeln. */ +/** Alle aktiven Backup-Ziele einsammeln (inkl. Metadaten-Beileger-Flag pro Ziel). */ export async function backupDestinations(): Promise { const dests: BackupDest[] = []; const nas = await loadNasConfig(); - if (nas) dests.push({ key: 'nas', label: 'NAS', cfg: nas }); - const targets = await query(`SELECT id, name FROM delivery_targets WHERE is_backup=true`); + if (nas) { + const s = await one('SELECT nas_metadata_sidecar FROM settings WHERE id=1'); + dests.push({ key: 'nas', label: 'NAS', cfg: nas, sidecar: !!s?.nas_metadata_sidecar }); + } + const targets = await query(`SELECT id, name, metadata_sidecar FROM delivery_targets WHERE is_backup=true`); for (const t of targets) { const cfg = await loadTargetConfig(t.id); - if (cfg) dests.push({ key: t.id, label: t.name, cfg }); + if (cfg) dests.push({ key: t.id, label: t.name, cfg, sidecar: !!t.metadata_sidecar }); } return dests; } -async function sidecarEnabled(): Promise { - const s = await one<{ metadata_sidecar: boolean }>('SELECT metadata_sidecar FROM settings WHERE id=1'); - return !!s?.metadata_sidecar; -} - /** Ein Item auf alle Backup-Ziele spiegeln. */ export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> { const dests = await backupDestinations(); @@ -36,14 +34,15 @@ export async function backupItem(itemId: string): Promise<{ mirrored: number; fa const buf = await getObject(it.result_path); const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7); - const withMeta = await sidecarEnabled(); - const md = withMeta ? Buffer.from(await buildMetadataMd(itemId), 'utf8') : null; + let md: Buffer | null = null; + const anySidecar = dests.some((d) => d.sidecar); + if (anySidecar) md = Buffer.from(await buildMetadataMd(itemId), 'utf8'); let mirrored = 0, failed = 0; for (const d of dests) { try { await uploadBuffer(d.cfg, ym, it.filename || `${it.id}.png`, buf); - if (md) await uploadBuffer(d.cfg, ym, sidecarName(it.filename), md); + if (d.sidecar && md) await uploadBuffer(d.cfg, ym, sidecarName(it.filename), md); await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'mirrored') ON CONFLICT (item_id, target) DO UPDATE SET status='mirrored', at=now()`, [itemId, d.key]); if (d.key === 'nas') await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]); diff --git a/src/lib/delivery.ts b/src/lib/delivery.ts index 84e8aa1..43e2e6b 100644 --- a/src/lib/delivery.ts +++ b/src/lib/delivery.ts @@ -24,33 +24,42 @@ async function cfgForKey(key: string | null | undefined): Promise { +/** Ziel-Zugang inkl. Quell-Schlüssel: 1) Ordner-Ziel 2) Rezept-Ziel 3) Standard-Picdrop. */ +async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<{ cfg: PicdropCfg; key: string } | null> { if (item.folder_id) { const f = await one<{ delivery_target_id: string | null }>('SELECT delivery_target_id FROM folders WHERE id=$1', [item.folder_id]).catch(() => null); - if (f?.delivery_target_id) { const c = await cfgForKey(f.delivery_target_id); if (c) return c; } + if (f?.delivery_target_id) { const c = await cfgForKey(f.delivery_target_id); if (c) return { cfg: c, key: f.delivery_target_id }; } } const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]); const tid = j?.recipe_snapshot?.delivery_target_id; - if (tid) { const c = await cfgForKey(tid); if (c) return c; } - return loadConfig(); // Standard-Picdrop + if (tid) { const c = await cfgForKey(tid); if (c) return { cfg: c, key: tid }; } + const c = await loadConfig(); + return c ? { cfg: c, key: 'picdrop' } : null; +} + +/** Ist der Metadaten-Beileger für diese Quelle aktiv? */ +async function sidecarForKey(key: string): Promise { + if (key === 'picdrop') { const s = await one('SELECT picdrop_metadata_sidecar FROM settings WHERE id=1'); return !!s?.picdrop_metadata_sidecar; } + if (key === 'nas') { const s = await one('SELECT nas_metadata_sidecar FROM settings WHERE id=1'); return !!s?.nas_metadata_sidecar; } + const t = await one('SELECT metadata_sidecar FROM delivery_targets WHERE id=$1', [key]); + return !!t?.metadata_sidecar; } /** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */ export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> { const it = await one('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]); if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' }; - const cfg = await targetFor(it); - if (!cfg) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Kein Auslieferungsziel konfiguriert.' }; } + const target = await targetFor(it); + if (!target) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Kein Auslieferungsziel konfiguriert.' }; } + const { cfg, key } = target; await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]); try { const gallery = await galleryFor(it); const buf = await getObject(it.result_path); await uploadBuffer(cfg, gallery, it.filename || `${it.id}.png`, buf); - // Optional: Metadaten als begleitende .md-Datei mitschicken. - const md = await one<{ metadata_sidecar: boolean }>('SELECT metadata_sidecar FROM settings WHERE id=1'); - if (md?.metadata_sidecar) { + // Optional: Metadaten als begleitende .md-Datei — pro Quelle steuerbar. + if (await sidecarForKey(key)) { try { const { buildMetadataMd, sidecarName } = await import('./metadata'); await uploadBuffer(cfg, gallery, sidecarName(it.filename), Buffer.from(await buildMetadataMd(itemId), 'utf8')); diff --git a/src/lib/maintenance.ts b/src/lib/maintenance.ts index e6a9c26..751f078 100644 --- a/src/lib/maintenance.ts +++ b/src/lib/maintenance.ts @@ -54,6 +54,26 @@ export async function runRetention(days: number): Promise<{ deleted: number }> { return { deleted: rows.length }; } +/** Erzeugt fehlende Vorschaubilder nach (für schnelle Bibliotheks-Anzeige). */ +export async function rebuildThumbs(): Promise<{ made: number }> { + const { getObject, putObject, thumbKey } = await import('./storage'); + const sharp = (await import('sharp')).default; + const rows = await query( + `SELECT id, result_path FROM items WHERE status='done' AND result_path IS NOT NULL AND thumb_path IS NULL`); + let made = 0; + for (const it of rows) { + try { + const out = await sharp(await getObject(it.result_path), { failOn: 'none' }) + .resize(600, 600, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 72 }).toBuffer(); + const tk = thumbKey(it.id); + await putObject(tk, out, 'image/webp'); + await query(`UPDATE items SET thumb_path=$2 WHERE id=$1`, [it.id, tk]); + made++; + } catch (e) { console.error('[maintenance] Thumb', it.id, e); } + } + return { made }; +} + /** Löscht nur die Quellbilder fertiger Positionen (Ergebnisse bleiben). */ export async function purgeSources(): Promise<{ purged: number }> { const rows = await query( diff --git a/src/pages/api/admin/delivery-targets.ts b/src/pages/api/admin/delivery-targets.ts index 9fea0d8..6b679b5 100644 --- a/src/pages/api/admin/delivery-targets.ts +++ b/src/pages/api/admin/delivery-targets.ts @@ -8,7 +8,7 @@ const json = (b: unknown, s = 200) => new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); export const GET: APIRoute = async () => { - const rows = await query(`SELECT id, name, protocol, host, port, username, base_path, is_backup, + const rows = await query(`SELECT id, name, protocol, host, port, username, base_path, is_backup, metadata_sidecar, (password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`); return json({ targets: rows }); }; @@ -27,10 +27,10 @@ export const POST: APIRoute = async ({ request }) => { } if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400); const row = await one( - `INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, + `INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup, metadata_sidecar) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, [b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null, - b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup]); + b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup, !!b.metadata_sidecar]); return json({ id: row!.id }); }; @@ -44,6 +44,7 @@ export const PATCH: APIRoute = async ({ request }) => { if (c in b) set(c, b[c] === '' ? null : b[c]); } if ('is_backup' in b) set('is_backup', !!b.is_backup); + if ('metadata_sidecar' in b) set('metadata_sidecar', !!b.metadata_sidecar); if (b.password) set('password_enc', encrypt(String(b.password))); if (!sets.length) return json({ ok: true }); args.push(b.id); diff --git a/src/pages/api/admin/picdrop-ls.ts b/src/pages/api/admin/picdrop-ls.ts new file mode 100644 index 0000000..e86d674 --- /dev/null +++ b/src/pages/api/admin/picdrop-ls.ts @@ -0,0 +1,36 @@ +import type { APIRoute } from 'astro'; +import posixpath from 'node:path/posix'; +import { loadConfig } from '../../../lib/picdrop'; + +export const prerender = false; +const json = (b: unknown, s = 200) => + new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); + +/** Diagnose: listet einen Picdrop-Ordner (Standard: Basisordner). Body { path? } */ +export const POST: APIRoute = async ({ request }) => { + const b = await request.json().catch(() => ({} as any)); + const cfg = await loadConfig(); + if (!cfg) return json({ error: 'Picdrop nicht konfiguriert.' }, 400); + const path = b.path || cfg.basePath || '/'; + try { + if (cfg.protocol === 'sftp') { + const SftpClient = (await import('ssh2-sftp-client')).default; + const c = new SftpClient(); + await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 }); + try { + const list = await c.list(path); + return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) }); + } finally { await c.end(); } + } else { + const { Client } = await import('basic-ftp'); + const c = new Client(15000); + await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true }); + try { + const list = await c.list(path); + return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) }); + } finally { c.close(); } + } + } catch (e: any) { + return json({ path, error: e?.message || 'Listing fehlgeschlagen.' }); + } +}; diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index 711fd17..8ca82e1 100644 --- a/src/pages/api/admin/settings.ts +++ b/src/pages/api/admin/settings.ts @@ -24,6 +24,7 @@ export const GET: APIRoute = async () => { // Speicherverwaltung keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days, metadata_sidecar: s.metadata_sidecar, api_token: s.api_token, + picdrop_metadata_sidecar: s.picdrop_metadata_sidecar, nas_metadata_sidecar: s.nas_metadata_sidecar, // Rechte / Datenschutz / NSFW library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations, private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw, @@ -43,10 +44,11 @@ export const PATCH: APIRoute = async ({ request }) => { if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password))); if (b.generate_api_token) set('api_token', randomToken()); const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar', - 'anonymous_generations', 'private_allowed', 'allow_nsfw']; + 'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw']; for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path', 'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url', 'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token', + 'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw', 'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) { if (col in b) { diff --git a/src/pages/api/admin/storage.ts b/src/pages/api/admin/storage.ts index f75c2ba..8684d2b 100644 --- a/src/pages/api/admin/storage.ts +++ b/src/pages/api/admin/storage.ts @@ -1,5 +1,5 @@ import type { APIRoute } from 'astro'; -import { storageStats, runRetention, purgeSources, mirrorAllToNas } from '../../../lib/maintenance'; +import { storageStats, runRetention, purgeSources, mirrorAllToNas, rebuildThumbs } from '../../../lib/maintenance'; import { one } from '../../../lib/db'; export const prerender = false; @@ -13,6 +13,7 @@ export const POST: APIRoute = async ({ request }) => { const b = await request.json().catch(() => ({})); if (b.action === 'purge_sources') return json(await purgeSources()); if (b.action === 'mirror_all') return json(await mirrorAllToNas()); + if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs()); if (b.action === 'retention') { const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1'); if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400); diff --git a/src/pages/api/items/[id]/file.ts b/src/pages/api/items/[id]/file.ts index 627b05f..309552b 100644 --- a/src/pages/api/items/[id]/file.ts +++ b/src/pages/api/items/[id]/file.ts @@ -1,4 +1,5 @@ import type { APIRoute } from 'astro'; +import sharp from 'sharp'; import { one } from '../../../../lib/db'; import { getObject } from '../../../../lib/storage'; @@ -28,13 +29,19 @@ export const GET: APIRoute = async ({ params, url, locals }) => { : item?.result_path; if (!path) return new Response('Nicht gefunden', { status: 404 }); try { - const buf = await getObject(path); + let buf = await getObject(path); const download = q.get('download') === '1'; - const isWebp = path.endsWith('.webp'); + let contentType = path.endsWith('.webp') ? 'image/webp' : 'image/png'; + // Schnelle Vollbild-Vorschau: nicht-transparente Ergebnisse als JPEG (deutlich kleiner, + // schneller auf Mobil) — reicht fürs „In Fotos sichern". Der PNG-Download bleibt unverändert. + if (q.get('preview') === '1' && !download && !item?.has_alpha) { + try { buf = await sharp(buf, { failOn: 'none' }).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; } + catch { /* Fallback: Original */ } + } return new Response(buf, { headers: { - 'Content-Type': isWebp ? 'image/webp' : 'image/png', - 'Cache-Control': 'private, max-age=300', + 'Content-Type': contentType, + 'Cache-Control': 'private, max-age=600', ...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}), }, });