diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index 04bd889..d1b2efb 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -101,6 +101,12 @@ export default function AdminApp() { 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 cleanupPicdrop = async () => { + const gallery = s.picdrop_default_gallery || 'POSTER LEA'; + notify('Räume Reste auf …'); + const r = await fetch('/api/admin/picdrop-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'cleanup_tmp', gallery }) }).then((x) => x.json()); + notify(r.error ? r.error : `${r.cleaned ?? 0} Reste in „${gallery}" entfernt.`); + }; const testNas = async () => { notify('Teste NAS …'); const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json()); @@ -204,6 +210,7 @@ export default function AdminApp() {
Picdrop
+
diff --git a/src/lib/delivery.ts b/src/lib/delivery.ts index 43e2e6b..18e0a2b 100644 --- a/src/lib/delivery.ts +++ b/src/lib/delivery.ts @@ -45,9 +45,33 @@ async function sidecarForKey(key: string): Promise { return !!t?.metadata_sidecar; } +/** Picdrop ist ein Foto-Proofing-Tool und erwartet handliche JPGs, keine 30-MB-PNGs. + * Ergebnisse ohne Transparenz werden darum als JPEG (q92) ausgeliefert; freigestellte + * Motive (Sticker mit Alpha) bleiben PNG. Rückgabe: umgewandelter Puffer + passender Name. */ +async function deliverableBuffer( + buf: Buffer, filename: string, hasAlpha: boolean, +): Promise<{ buf: Buffer; name: string }> { + if (hasAlpha) return { buf, name: filename }; + try { + const sharp = (await import('sharp')).default; + const meta = await sharp(buf, { failOn: 'none' }).metadata(); + const dpi = meta.density && meta.density > 0 ? meta.density : 300; + const jpg = await sharp(buf, { failOn: 'none' }) + .flatten({ background: '#ffffff' }) + .withMetadata({ density: dpi }) + .jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' }) + .toBuffer(); + const name = filename.replace(/\.(png|webp|tiff?)$/i, '.jpg'); + return { buf: jpg, name: /\.jpe?g$/i.test(name) ? name : name + '.jpg' }; + } catch (e) { + console.error('[delivery] JPEG-Wandlung fehlgeschlagen, sende Original', e); + return { buf, name: filename }; + } +} + /** 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]); + const it = await one('SELECT id, result_path, filename, folder_id, job_id, has_alpha FROM items WHERE id=$1', [itemId]); if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' }; 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.' }; } @@ -56,13 +80,14 @@ export async function deliverItem(itemId: string): Promise<{ ok: boolean; messag 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); + const raw = await getObject(it.result_path); + const { buf, name } = await deliverableBuffer(raw, it.filename || `${it.id}.png`, !!it.has_alpha); + await uploadBuffer(cfg, gallery, name, buf); // 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')); + await uploadBuffer(cfg, gallery, sidecarName(name), Buffer.from(await buildMetadataMd(itemId), 'utf8')); } catch (e) { console.error('[delivery] Metadaten-Beileger fehlgeschlagen', e); } } await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]); diff --git a/src/lib/pipeline.ts b/src/lib/pipeline.ts index 8f69986..e958c6c 100644 --- a/src/lib/pipeline.ts +++ b/src/lib/pipeline.ts @@ -93,11 +93,21 @@ export function slugify(s: string): string { const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']); +/** Motiv aus einem beliebigen (evtl. schon verarbeiteten) Namen herausschälen: + * Datumspräfixe, UUIDs und lange Hex-Ketten entfernen, damit keine Doppelungen entstehen. */ +function cleanMotif(name: string | null): string { + let s = (name || '').replace(/\.[^.]+$/, ''); + s = s.replace(/\b\d{4}-\d{2}-\d{2}\b/g, ' '); // Datum + s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID + s = s.replace(/\b[0-9a-f]{16,}\b/gi, ' '); // lange Hex-Ketten + return slugify(s); +} + /** Logischer Dateiname: JJJJ-MM-TT__. * z. B. 2026-07-23_lea_the-frame.png */ export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string { - const raw = slugify((originalName || '').replace(/\.[^.]+$/, '')); - const motif = GENERIC.has(raw) ? 'klarbild' : raw; + const raw = cleanMotif(originalName); + const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw; const date = new Date().toISOString().slice(0, 10); const fmt = slugify(formatToken) || 'original'; return `${date}_${motif}_${fmt}.${ext}`; diff --git a/src/pages/api/admin/picdrop-ls.ts b/src/pages/api/admin/picdrop-ls.ts index e86d674..c639e50 100644 --- a/src/pages/api/admin/picdrop-ls.ts +++ b/src/pages/api/admin/picdrop-ls.ts @@ -1,16 +1,23 @@ import type { APIRoute } from 'astro'; import posixpath from 'node:path/posix'; -import { loadConfig } from '../../../lib/picdrop'; +import { loadConfig, cleanupTmp } 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? } */ +/** Diagnose: listet einen Picdrop-Ordner (Standard: Basisordner). Body { path? } + * Aufräumen: Body { action:'cleanup_tmp', gallery } entfernt liegengebliebene .tmp-Dateien. */ 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); + if (b.action === 'cleanup_tmp') { + try { + const removed = await cleanupTmp(cfg, b.gallery || ''); + return json({ cleaned: removed }); + } catch (e: any) { return json({ error: e?.message || 'Aufräumen fehlgeschlagen.' }, 500); } + } const path = b.path || cfg.basePath || '/'; try { if (cfg.protocol === 'sftp') {