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:
@@ -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;
|
||||
@@ -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<any>(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() {
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Picdrop</span>
|
||||
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div>
|
||||
<div className="reihe"><button className="mini" onClick={() => lsPicdrop()}>Ordner anzeigen</button>
|
||||
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div></div>
|
||||
<div className="steuer">
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Host</label><input className="input" value={s.picdrop_host ?? ''} onChange={(e) => field('picdrop_host', e.target.value)} /></div>
|
||||
@@ -211,7 +222,20 @@ export default function AdminApp() {
|
||||
<div className="feld"><label>Basisordner (SFTP-Wurzel)</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
|
||||
<div className="feld"><label>Standard-Galerie</label><input className="input" placeholder="POSTER LEA" value={s.picdrop_default_gallery ?? ''} onChange={(e) => field('picdrop_default_gallery', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> — z. B. <code>/ + POSTER LEA</code>. Pro Ordner lässt sich später eine eigene Galerie mappen.</div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.picdrop_metadata_sidecar} onChange={(e) => field('picdrop_metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten (.md) an Picdrop mitschicken</b><em>Nur aktivieren, wenn Picdrop die Textdateien verarbeiten soll — sonst aus lassen.</em></span></label>
|
||||
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> — z. B. <code>/ + POSTER LEA</code>. „Ordner anzeigen" listet, was per SFTP tatsächlich wo liegt.</div>
|
||||
{pdList && (
|
||||
<div className="pdlist">
|
||||
<div className="fein"><b>{pdList.path}</b>{pdList.error ? ` — ${pdList.error}` : ''}</div>
|
||||
{(pdList.entries || []).map((e: any, i: number) => (
|
||||
<div key={i} className="pdrow">
|
||||
<button className="link2" onClick={() => lsPicdrop((pdList.path.replace(/\/$/, '')) + '/' + e.name)}>{e.type === 'd' || e.type === 2 ? '📁' : '📄'} {e.name}</button>
|
||||
<span className="fein">{e.size ? `${Math.round(e.size / 1024)} KB` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -223,13 +247,13 @@ export default function AdminApp() {
|
||||
<span><b>Vorschaubilder erzeugen</b><em>Kleine, sparsame Bilder für die Bibliotheksvorschau.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
|
||||
<span><b>Quellbilder nach Bearbeitung löschen</b><em>Spart Platz — Originale werden nach dem Ergebnis entfernt.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.metadata_sidecar} onChange={(e) => field('metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten als .md mitschicken</b><em>Zu jedem Bild eine begleitende Textdatei (Prompt, Modell, Format …) bei Picdrop/NAS/FTP.</em></span></label>
|
||||
<div className="fein">Metadaten-Beileger (.md mit Prompt/Modell/Format) lassen sich jetzt <b>pro Ziel</b> ein-/ausschalten — bei Picdrop, beim NAS und bei jedem FTP-Ziel einzeln (siehe die jeweilige Karte).</div>
|
||||
<div className="feld"><label>Aufbewahrung (Tage)</label>
|
||||
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
||||
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
|
||||
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>Vorschaubilder nachgenerieren</button>
|
||||
{s.retention_days ? <button className="mini" onClick={() => storageAction('retention', `Positionen älter als ${s.retention_days} Tage jetzt löschen?`)}>Retention jetzt anwenden</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,6 +279,8 @@ export default function AdminApp() {
|
||||
<div className="feld"><label>Passwort {s.nas_password_set && <em>(gesetzt)</em>}</label>
|
||||
<input className="input" type="password" placeholder={s.nas_password_set ? '••••••' : ''} value={nasPw} onChange={(e) => setNasPw(e.target.value)} /></div>
|
||||
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/Klarbild" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.nas_metadata_sidecar} onChange={(e) => field('nas_metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten (.md) aufs NAS mitschicken</b><em>Begleitende Textdatei (Prompt, Modell, Format …) neben jedem gesicherten Bild.</em></span></label>
|
||||
<button className="mini" onClick={mirrorAll}>Alle vorhandenen Bilder aufs NAS sichern</button>
|
||||
<div className="fein">Ergebnisse landen unter <code>Basisordner / JJJJ-MM /</code>. Zugangsdaten werden verschlüsselt gespeichert.</div>
|
||||
</div>
|
||||
@@ -270,9 +296,10 @@ export default function AdminApp() {
|
||||
<div className="liste">
|
||||
{targets.map((t) => (
|
||||
<div key={t.id} className="zeile">
|
||||
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
|
||||
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>}{t.metadata_sidecar && <span className="pille alpha">.md</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => toggleBackup(t)}>{t.is_backup ? 'Backup aus' : 'Als Backup'}</button>
|
||||
<button className="mini" onClick={() => toggleTargetSidecar(t)}>{t.metadata_sidecar ? '.md aus' : '.md an'}</button>
|
||||
<button className="mini" onClick={() => testTarget(t.id)}>Test</button>
|
||||
<button className="mini" onClick={() => delTarget(t.id)}>✕</button>
|
||||
</div>
|
||||
@@ -297,6 +324,8 @@ export default function AdminApp() {
|
||||
<div className="feld"><label>Basisordner</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
|
||||
<span><b>Als Backup-Ziel</b><em>Erhält automatisch alle Ergebnisse.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!!nt.metadata_sidecar} onChange={(e) => setNt({ ...nt, metadata_sidecar: e.target.checked })} />
|
||||
<span><b>Metadaten (.md) mitschicken</b><em>Begleitende Textdatei je Bild.</em></span></label>
|
||||
<button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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;}
|
||||
`}</style>;
|
||||
}
|
||||
|
||||
@@ -249,10 +249,13 @@ export default function LibraryApp() {
|
||||
)}
|
||||
{saveView && (
|
||||
<div className="lupe" onClick={() => setSaveView(null)}>
|
||||
<img className="saveimg" src={`/api/items/${saveView.id}/file`} alt={saveView.filename || ''} onClick={(e) => e.stopPropagation()} />
|
||||
<div className="savewrap" onClick={(e) => e.stopPropagation()}>
|
||||
<img className="saveimg blur" src={`/api/items/${saveView.id}/file?thumb=1`} alt="" />
|
||||
<img className="saveimg full" src={`/api/items/${saveView.id}/file?preview=1`} alt={saveView.filename || ''} />
|
||||
</div>
|
||||
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
|
||||
<span>📱 iPhone/iPad: lange auf das Bild tippen → <b>„In Fotos sichern"</b>.</span>
|
||||
<a className="mini stark" href={`/api/items/${saveView.id}/file?download=1`}>Als Datei herunterladen</a>
|
||||
<a className="mini stark" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
|
||||
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;}
|
||||
|
||||
@@ -96,10 +96,10 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
|
||||
</div>
|
||||
{saveView && (
|
||||
<div className="lupe" onClick={() => setSaveView(null)}>
|
||||
<img className="saveimg" src={`/api/items/${saveView.id}/file`} alt="" onClick={(e) => e.stopPropagation()} />
|
||||
<img className="saveimg" src={`/api/items/${saveView.id}/file?preview=1`} alt="" onClick={(e) => e.stopPropagation()} />
|
||||
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
|
||||
<span>📱 iPhone/iPad: lange auf das Bild tippen → „In Fotos sichern".</span>
|
||||
<a className="mini" href={`/api/items/${saveView.id}/file?download=1`}>Als Datei herunterladen</a>
|
||||
<a className="mini" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
|
||||
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-13
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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<any>(`SELECT id, name, protocol, host, port, username, base_path, is_backup,
|
||||
const rows = await query<any>(`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<any>(
|
||||
`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);
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'}"` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user