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
+4
View File
@@ -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;
+39 -7
View File
@@ -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 }) }); 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(); 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 () => { const backupAllTargets = async () => {
if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return; if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return;
notify('Sichere … (kann dauern)'); 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, 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, 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, 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, library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw, 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, 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()); const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler')); 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 () => { const testNas = async () => {
notify('Teste NAS …'); notify('Teste NAS …');
const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json()); 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; if (confirmMsg && !confirm(confirmMsg)) return;
notify('Räume auf …'); 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()); 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(); load();
}; };
const mirrorAll = async () => { const mirrorAll = async () => {
@@ -193,7 +203,8 @@ export default function AdminApp() {
<section className="karte"> <section className="karte">
<div className="kopfzeile"><span className="mono-label">Picdrop</span> <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="steuer">
<div className="zwei"> <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> <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>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 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>
<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> </div>
</section> </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> <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)} /> <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> <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)} /> <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>
<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="feld"><label>Aufbewahrung (Tage)</label> <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)} /> <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="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
<div className="reihe"> <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('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} {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>
</div> </div>
@@ -255,6 +279,8 @@ export default function AdminApp() {
<div className="feld"><label>Passwort {s.nas_password_set && <em>(gesetzt)</em>}</label> <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> <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> <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> <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 className="fein">Ergebnisse landen unter <code>Basisordner / JJJJ-MM /</code>. Zugangsdaten werden verschlüsselt gespeichert.</div>
</div> </div>
@@ -270,9 +296,10 @@ export default function AdminApp() {
<div className="liste"> <div className="liste">
{targets.map((t) => ( {targets.map((t) => (
<div key={t.id} className="zeile"> <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"> <div className="reihe">
<button className="mini" onClick={() => toggleBackup(t)}>{t.is_backup ? 'Backup aus' : 'Als Backup'}</button> <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={() => testTarget(t.id)}>Test</button>
<button className="mini" onClick={() => delTarget(t.id)}>✕</button> <button className="mini" onClick={() => delTarget(t.id)}>✕</button>
</div> </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> <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 })} /> <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> <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> <button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
</div> </div>
</section> </section>
@@ -435,5 +464,8 @@ function AdminStyles() {
.reihe{display:flex;gap:6px;} .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);} .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;} .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>; `}</style>;
} }
+8 -2
View File
@@ -249,10 +249,13 @@ export default function LibraryApp() {
)} )}
{saveView && ( {saveView && (
<div className="lupe" onClick={() => setSaveView(null)}> <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()}> <div className="save-fuss" onClick={(e) => e.stopPropagation()}>
<span>📱 iPhone/iPad: lange auf das Bild tippen <b>In Fotos sichern"</b>.</span> <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> <button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
</div> </div>
</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{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 button:hover{background:var(--paper);}
.tagmenu .flag{font-size:15px;} .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{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;} .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;} .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;} .varianten{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
+2 -2
View File
@@ -96,10 +96,10 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
</div> </div>
{saveView && ( {saveView && (
<div className="lupe" onClick={() => setSaveView(null)}> <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()}> <div className="save-fuss" onClick={(e) => e.stopPropagation()}>
<span>📱 iPhone/iPad: lange auf das Bild tippen In Fotos sichern".</span> <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> <button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
</div> </div>
</div> </div>
+12 -13
View File
@@ -7,26 +7,24 @@ import { uploadBuffer, loadTargetConfig, type PicdropCfg } from './picdrop';
import { loadNasConfig } from './nas'; import { loadNasConfig } from './nas';
import { buildMetadataMd, sidecarName } from './metadata'; 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[]> { export async function backupDestinations(): Promise<BackupDest[]> {
const dests: BackupDest[] = []; const dests: BackupDest[] = [];
const nas = await loadNasConfig(); const nas = await loadNasConfig();
if (nas) dests.push({ key: 'nas', label: 'NAS', cfg: nas }); if (nas) {
const targets = await query<any>(`SELECT id, name FROM delivery_targets WHERE is_backup=true`); 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) { for (const t of targets) {
const cfg = await loadTargetConfig(t.id); 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; 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. */ /** Ein Item auf alle Backup-Ziele spiegeln. */
export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> { export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> {
const dests = await backupDestinations(); 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 buf = await getObject(it.result_path);
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7); const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7);
const withMeta = await sidecarEnabled(); let md: Buffer | null = null;
const md = withMeta ? Buffer.from(await buildMetadataMd(itemId), 'utf8') : null; const anySidecar = dests.some((d) => d.sidecar);
if (anySidecar) md = Buffer.from(await buildMetadataMd(itemId), 'utf8');
let mirrored = 0, failed = 0; let mirrored = 0, failed = 0;
for (const d of dests) { for (const d of dests) {
try { try {
await uploadBuffer(d.cfg, ym, it.filename || `${it.id}.png`, buf); 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') 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]); 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]); 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 return loadTargetConfig(key); // UUID eines Zusatzziels
} }
/** Ziel-Zugang: 1) Ordner-Ziel 2) Rezept-Ziel 3) Standard-Picdrop (Einstellungen). */ /** 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<PicdropCfg | null> { async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<{ cfg: PicdropCfg; key: string } | null> {
if (item.folder_id) { 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); 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 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; const tid = j?.recipe_snapshot?.delivery_target_id;
if (tid) { const c = await cfgForKey(tid); if (c) return c; } if (tid) { const c = await cfgForKey(tid); if (c) return { cfg: c, key: tid }; }
return loadConfig(); // Standard-Picdrop 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. */ /** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */
export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> { 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]); 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.' }; if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
const cfg = await targetFor(it); const target = await targetFor(it);
if (!cfg) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Kein Auslieferungsziel konfiguriert.' }; } 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]); await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]);
try { try {
const gallery = await galleryFor(it); const gallery = await galleryFor(it);
const buf = await getObject(it.result_path); const buf = await getObject(it.result_path);
await uploadBuffer(cfg, gallery, it.filename || `${it.id}.png`, buf); await uploadBuffer(cfg, gallery, it.filename || `${it.id}.png`, buf);
// Optional: Metadaten als begleitende .md-Datei mitschicken. // Optional: Metadaten als begleitende .md-Datei — pro Quelle steuerbar.
const md = await one<{ metadata_sidecar: boolean }>('SELECT metadata_sidecar FROM settings WHERE id=1'); if (await sidecarForKey(key)) {
if (md?.metadata_sidecar) {
try { try {
const { buildMetadataMd, sidecarName } = await import('./metadata'); const { buildMetadataMd, sidecarName } = await import('./metadata');
await uploadBuffer(cfg, gallery, sidecarName(it.filename), Buffer.from(await buildMetadataMd(itemId), 'utf8')); 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 }; 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). */ /** Löscht nur die Quellbilder fertiger Positionen (Ergebnisse bleiben). */
export async function purgeSources(): Promise<{ purged: number }> { export async function purgeSources(): Promise<{ purged: number }> {
const rows = await query<any>( const rows = await query<any>(
+5 -4
View File
@@ -8,7 +8,7 @@ const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => { 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`); (password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
return json({ targets: rows }); return json({ targets: rows });
}; };
@@ -27,10 +27,10 @@ export const POST: APIRoute = async ({ request }) => {
} }
if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400); if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400);
const row = await one<any>( const row = await one<any>(
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup) `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) RETURNING id`, 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.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 }); 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 (c in b) set(c, b[c] === '' ? null : b[c]);
} }
if ('is_backup' in b) set('is_backup', !!b.is_backup); 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 (b.password) set('password_enc', encrypt(String(b.password)));
if (!sets.length) return json({ ok: true }); if (!sets.length) return json({ ok: true });
args.push(b.id); args.push(b.id);
+36
View File
@@ -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.' });
}
};
+3 -1
View File
@@ -24,6 +24,7 @@ export const GET: APIRoute = async () => {
// Speicherverwaltung // Speicherverwaltung
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days, 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, 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 // Rechte / Datenschutz / NSFW
library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations, library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations,
private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw, 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.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
if (b.generate_api_token) set('api_token', randomToken()); if (b.generate_api_token) set('api_token', randomToken());
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar', 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', 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', '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', 'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
'picdrop_metadata_sidecar', 'nas_metadata_sidecar',
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw', 'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) { 'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
if (col in b) { if (col in b) {
+2 -1
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from 'astro'; 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'; import { one } from '../../../lib/db';
export const prerender = false; export const prerender = false;
@@ -13,6 +13,7 @@ export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({})); const b = await request.json().catch(() => ({}));
if (b.action === 'purge_sources') return json(await purgeSources()); if (b.action === 'purge_sources') return json(await purgeSources());
if (b.action === 'mirror_all') return json(await mirrorAllToNas()); if (b.action === 'mirror_all') return json(await mirrorAllToNas());
if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs());
if (b.action === 'retention') { if (b.action === 'retention') {
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1'); 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); if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400);
+11 -4
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import sharp from 'sharp';
import { one } from '../../../../lib/db'; import { one } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage'; import { getObject } from '../../../../lib/storage';
@@ -28,13 +29,19 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
: item?.result_path; : item?.result_path;
if (!path) return new Response('Nicht gefunden', { status: 404 }); if (!path) return new Response('Nicht gefunden', { status: 404 });
try { try {
const buf = await getObject(path); let buf = await getObject(path);
const download = q.get('download') === '1'; 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, { return new Response(buf, {
headers: { headers: {
'Content-Type': isWebp ? 'image/webp' : 'image/png', 'Content-Type': contentType,
'Cache-Control': 'private, max-age=300', 'Cache-Control': 'private, max-age=600',
...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}), ...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}),
}, },
}); });