Files
SwipeAnything/adapters/immich.js
T
ilia 0c9919f93c 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.
2026-07-25 19:41:47 -04:00

156 lines
5.2 KiB
JavaScript

'use strict';
const { Readable } = require('stream');
const { Adapter } = require('./base');
/**
* Second reference adapter, deliberately different from the folder adapter:
* a remote API instead of the filesystem. Swipe through an Immich
* (https://immich.app) photo library -- "reject" moves the asset to
* Immich's own trash (its normal recoverable delete), "undo" restores it.
*
* Early adapter: tested against the documented API shape, not against every
* Immich version. If your server's search/metadata response looks different,
* please open a PR -- see CONTRIBUTING.md.
*/
class ImmichAdapter extends Adapter {
static id = 'immich';
static label = 'Immich';
static description =
'Swipe through a self-hosted Immich photo library. Reject moves the asset to Immich\u2019s own trash; undo restores it.';
static configSchema = [
{ key: 'serverUrl', label: 'Server URL', type: 'text', required: true, placeholder: 'https://immich.example.com' },
{ key: 'apiKey', label: 'API key', type: 'password', required: true },
{
key: 'mode',
label: 'Order',
type: 'select',
default: 'chronological',
options: [
{ value: 'chronological', label: 'Newest first' },
{ value: 'random', label: 'Random' },
],
},
{ key: 'take', label: 'How many to load per session', type: 'number', default: 100 },
{ key: 'skipVideos', label: 'Skip videos', type: 'checkbox', default: false },
];
static actions = [
{ id: 'keep', label: 'Keep', key: 'ArrowRight', direction: 'right' },
{ id: 'reject', label: 'Reject', key: 'ArrowLeft', direction: 'left', isDestructive: true },
{ id: 'skip', label: 'Skip', key: 'ArrowDown', direction: 'down' },
];
constructor(settings) {
super(settings);
this.serverUrl = String(settings.serverUrl || '').replace(/\/+$/, '');
this.apiKey = settings.apiKey || '';
this.mode = settings.mode === 'random' ? 'random' : 'chronological';
this.take = Number(settings.take) > 0 ? Number(settings.take) : 100;
this.skipVideos = Boolean(settings.skipVideos);
}
async _request(pathname, options = {}) {
if (!this.serverUrl) throw new Error('Server URL is required');
if (!this.apiKey) throw new Error('API key is required');
const url = `${this.serverUrl}/api${pathname}`;
let res;
try {
res = await fetch(url, {
...options,
headers: { 'x-api-key': this.apiKey, 'Content-Type': 'application/json', ...(options.headers || {}) },
});
} catch (err) {
throw new Error(`Could not reach Immich at ${this.serverUrl}: ${err.message}`);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Immich API ${pathname} failed: ${res.status} ${res.statusText} ${body.slice(0, 200)}`);
}
return res;
}
async init() {
// Fails fast with a clear message if the URL/key are wrong, before we
// ever try to build a queue.
await this._request('/users/me');
}
async list() {
let assets;
if (this.mode === 'random') {
const res = await this._request(`/assets/random?count=${this.take}`);
assets = await res.json();
} else {
const res = await this._request('/search/metadata', {
method: 'POST',
body: JSON.stringify({ take: this.take, order: 'desc' }),
});
const body = await res.json();
assets = body.assets ? body.assets.items : Array.isArray(body) ? body : [];
}
return assets
.filter((asset) => !this.skipVideos || asset.type !== 'VIDEO')
.map((asset) => ({
id: asset.id,
title: asset.originalFileName || asset.id,
subtitle: (asset.exifInfo && asset.exifInfo.dateTimeOriginal) || asset.fileCreatedAt || undefined,
previewType: asset.type === 'VIDEO' ? 'video' : 'image',
meta: {
type: asset.type === 'VIDEO' ? 'video' : 'photo',
favorite: asset.isFavorite ? 'yes' : 'no',
},
}));
}
async _streamAssetImage(assetId, size, res) {
let upstream;
try {
upstream = await this._request(`/assets/${assetId}/thumbnail?size=${size}`);
} catch {
return false;
}
if (!upstream.body) return false;
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg');
Readable.fromWeb(upstream.body).pipe(res);
return true;
}
async streamPreview(itemId, res) {
return this._streamAssetImage(itemId, 'preview', res);
}
async streamThumbnail(itemId, res) {
return this._streamAssetImage(itemId, 'thumbnail', res);
}
async applyAction(item, actionId) {
if (actionId === 'reject') {
await this._request('/assets', {
method: 'DELETE',
body: JSON.stringify({ ids: [item.id], force: false }),
});
return { type: 'trash', assetId: item.id };
}
// 'keep' and 'skip' have no effect on the library.
return null;
}
async undo(record) {
if (record && record.type === 'trash') {
await this._request('/trash/restore/assets', {
method: 'POST',
body: JSON.stringify({ ids: [record.assetId] }),
});
}
}
describeSource() {
return `${this.serverUrl} (Immich, ${this.mode})`;
}
}
module.exports = { ImmichAdapter };