Add second adapter, thumbnails, a11y pass, and a real test suite
- Immich adapter (adapters/immich.js): swipe a self-hosted photo library over its REST API, proving the adapter contract works for remote sources, not just the filesystem. - Refactor the preview contract from resolvePreviewPath() to streamPreview()/streamThumbnail(), so adapters can serve previews from anywhere (local file, proxied fetch, etc). - macOS Quick Look-backed thumbnail cache (lib/thumbnails.js): real resized thumbnails, HEIC/HEIF, and video poster frames, with a hard timeout and graceful fallback to the original file everywhere else. - Native "Browse..." folder picker (osascript) in Settings, with manual typing as the fallback on other platforms. - Confirm-guarded "Empty trash" action -- the only place this project permanently deletes anything. - Session resume: progress now survives a server restart and an unchanged Rescan via .swipeanything-session.json. - Accessibility pass: live region item announcements, aria-labels on action buttons, focus-trapped shortcuts dialog with focus restore, visible focus rings, a real role="progressbar", and <button>s instead of <a href="#"> in the header. - node:test suite (23 tests) covering the folder adapter, the Immich adapter (mocked fetch, no live server needed), and the HTTP API end-to-end; wired up as `npm test`. - Also includes the keyboard shortcuts (up/down undo/skip), swipe stamps, and shortcuts modal from the previous session that hadn't been committed yet.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
// Thumbnail generation for the local preview endpoint. Uses macOS's built-in
|
||||
// Quick Look generator (`qlmanage`) so we get resized previews, video poster
|
||||
// frames, and HEIC/HEIF support for free -- no extra npm dependencies, no
|
||||
// native binaries to compile. On non-macOS platforms this silently no-ops
|
||||
// and callers fall back to serving the original file.
|
||||
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const CACHE_DIR = path.join(__dirname, '..', '.cache', 'thumbnails');
|
||||
// qlmanage/quicklookd can hang indefinitely in some environments (headless
|
||||
// sessions, SSH, sandboxes without WindowServer access). Keep this short and
|
||||
// always hard-kill on timeout -- worse case is we fall back to the original
|
||||
// file a little slower, never a hung request.
|
||||
const GENERATE_TIMEOUT_MS = 3000;
|
||||
const IS_MACOS = process.platform === 'darwin';
|
||||
|
||||
let cacheDirReady = null;
|
||||
function ensureCacheDir() {
|
||||
if (!cacheDirReady) {
|
||||
cacheDirReady = fsp.mkdir(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
return cacheDirReady;
|
||||
}
|
||||
|
||||
function cacheKeyFor(absPath, mtimeMs) {
|
||||
return crypto.createHash('sha1').update(`${absPath}:${mtimeMs}`).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute path to a cached thumbnail PNG for `absPath`, or null
|
||||
* if thumbnails aren't supported on this platform / generation failed.
|
||||
* Safe to call for any file type; unsupported types just fail fast.
|
||||
*/
|
||||
async function getThumbnail(absPath, { size = 480 } = {}) {
|
||||
if (!IS_MACOS) return null;
|
||||
|
||||
const stat = await fsp.stat(absPath).catch(() => null);
|
||||
if (!stat) return null;
|
||||
|
||||
await ensureCacheDir();
|
||||
const key = cacheKeyFor(absPath, stat.mtimeMs);
|
||||
const cachedPath = path.join(CACHE_DIR, `${key}.png`);
|
||||
|
||||
if (fs.existsSync(cachedPath)) return cachedPath;
|
||||
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'swipeanything-thumb-'));
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
'qlmanage',
|
||||
['-t', '-s', String(size), '-o', tmpDir, absPath],
|
||||
{ timeout: GENERATE_TIMEOUT_MS, killSignal: 'SIGKILL' },
|
||||
(err) => (err ? reject(err) : resolve())
|
||||
);
|
||||
// Belt-and-suspenders: qlmanage/quicklookd have been observed to hang
|
||||
// past Node's own `timeout` option in some sandboxed environments.
|
||||
const hardKill = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('qlmanage timed out'));
|
||||
}, GENERATE_TIMEOUT_MS + 500);
|
||||
child.once('exit', () => clearTimeout(hardKill));
|
||||
});
|
||||
|
||||
const base = path.basename(absPath);
|
||||
const generated = path.join(tmpDir, `${base}.png`);
|
||||
if (!fs.existsSync(generated)) return null;
|
||||
|
||||
await fsp.rename(generated, cachedPath);
|
||||
return cachedPath;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getThumbnail, CACHE_DIR };
|
||||
Reference in New Issue
Block a user