Files
SwipeAnything/adapters/immich.js
T
ilia 6f0ccf2edc
CI / Gitleaks (pull_request) Successful in 17s
CI / Unit tests (pull_request) Successful in 20s
Polish settings UI and add Gitea Actions CI gate
Settings: highlight the selected adapter, restyle radios/password/select
for the dark theme, and keep saved settings scoped to the chosen adapter.
CI: run npm test + gitleaks on push/PR so the suite is an actual gate.
2026-07-26 10:56:53 -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's 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, placeholder: 'immich_api_key_…' },
{
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 };