feat: per-source .md sidecar toggles, Picdrop folder-list diagnostic, fast JPEG fullscreen preview + thumb-first, thumbnail backfill

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-24 07:56:57 +00:00
parent 5f0c33e44b
commit 32f06297a6
12 changed files with 161 additions and 44 deletions
+12 -13
View File
@@ -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<BackupDest[]> {
const dests: BackupDest[] = [];
const nas = await loadNasConfig();
if (nas) dests.push({ key: 'nas', label: 'NAS', cfg: nas });
const targets = await query<any>(`SELECT id, name FROM delivery_targets WHERE is_backup=true`);
if (nas) {
const s = await one<any>('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<any>(`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<boolean> {
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]);
+19 -10
View File
@@ -24,33 +24,42 @@ async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | n
return loadTargetConfig(key); // UUID eines Zusatzziels
}
/** Ziel-Zugang: 1) Ordner-Ziel 2) Rezept-Ziel 3) Standard-Picdrop (Einstellungen). */
async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<PicdropCfg | null> {
/** 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<boolean> {
if (key === 'picdrop') { const s = await one<any>('SELECT picdrop_metadata_sidecar FROM settings WHERE id=1'); return !!s?.picdrop_metadata_sidecar; }
if (key === 'nas') { const s = await one<any>('SELECT nas_metadata_sidecar FROM settings WHERE id=1'); return !!s?.nas_metadata_sidecar; }
const t = await one<any>('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<any>('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'));
+20
View File
@@ -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<any>(
`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<any>(