Files
RecipesCam/docker/frontend/scripts/immich-check.mjs
T
3dtours e27998d820 feat(immich): draw a server as a folder in the library
A saved Immich server is a node on the column beside the drives: no handle,
no picker — the key lives on the backend and the page only ever calls
`/api/immich/*` on its own origin. The dialog takes the address, the name and
the key, checks the key before storing anything, and offers the albums the key
can see; a blank tick list and a blank id field both mean every album. A share
key is one album and so has no picker.

`syncImmich` turns a server's albums into the shapes the tree already draws —
rows in `photos`, names in `walked` — so the column, the wall and the strip
need nothing new. The node's menu is the disk's menu minus what a server has
no disk for.

Also fixes `saveWalkedDirs` filling its noughts in before folding the keys:
a reading that spelled its keys the way it found them (a server's album
labels) had the lower-case spelling of a counted key written in as nought,
and the fold that settles the two spellings kept the nought.
2026-10-10 16:16:37 +07:00

415 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Immich as a folder beside the folders on the disk: the dialog that adds a
// server, what the column draws for it, what an album tick means, what the
// album-ID field makes of a whole address and of rubbish, and the one thing that
// must never happen — the page talking to Immich itself.
//
// The app answers `/api` on its own origin, and the Immich side of it is stood in
// for here the way `backend/src/immich.ts` answers: two keys that see two
// different sets of albums, one of them without the right to download originals,
// and a share link, which is one album and no picker.
//
// npm run build && node scripts/immich-check.mjs
// APP_PORT=4410 PLAYWRIGHT_CORE=<playwright-core/index.mjs> CHROME=<chrome> \
// node scripts/immich-check.mjs
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const APP_PORT = Number(process.env.APP_PORT ?? 4410);
let failures = 0;
function step(name, ok, detail = '') {
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
return ok;
}
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed');
process.exit(0);
}
// --- the server side of the check -------------------------------------------
// A uuid per thing, spelled the way the backend spells one: eight, four, four,
// four, twelve, all hex. The routes that take an id refuse anything else.
const uid = (n) => `${String(n).padStart(8, '0')}-0000-4000-8000-${String(n).padStart(12, '0')}`;
const A1 = uid(101);
const A2 = uid(102);
const B1 = uid(201);
const ALBUM_NAME = { [A1]: 'A1', [A2]: 'A2', [B1]: 'B1' };
// What each album holds: five frames over the first two, one over the third.
const ASSETS = [];
let nth = 0;
for (const [album, names] of [
[A1, ['A1_1.JPG', 'A1_2.JPG']],
[A2, ['A2_1.JPG', 'A2_2.JPG', 'A2_3.JPG']],
[B1, ['B1_1.JPG']],
]) {
for (const name of names) ASSETS.push({ id: uid(1000 + ++nth), album, name });
}
// The three boxes an address can name, and the key each of them answers to.
const HOSTS = {
'https://photos.example.com': { key: 'good-key', version: '1.120.0', canDownload: true, albums: [A1, A2] },
'https://view.example.com': { key: 'view-only', version: '1.119.0', canDownload: false, albums: [B1] },
'https://share.example.com': { key: 'share-xyz', version: null, canDownload: false, albums: [B1] },
};
// What the backend holds: a saved server, by the id it minted.
const saved = [];
let minted = 0;
// Album ids as the backend cleans them: a uuid out of whatever was typed, once
// each, at most two hundred of them.
function cleanAlbums(raw) {
const out = [];
for (const one of Array.isArray(raw) ? raw : []) {
const hit = String(one).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
const id = hit ? hit[0].toLowerCase() : '';
if (id && !out.includes(id)) out.push(id);
}
return out.slice(0, 200);
}
// One pixel of PNG, so a tile that lands is a picture the browser decodes.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
// Anything the page asks a photos host for directly. Nothing may: the address and
// the key live on the backend, and the page only ever calls its own origin.
let direct = 0;
let thumbs = 0;
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 500));
}
if (!up) {
app.kill('SIGTERM');
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
process.exit(1);
}
const browser = await playwright.chromium.launch({
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader'],
});
const context = await browser.newContext();
await context.route(/^https:\/\/[a-z]+\.example\.com\//, (route) => {
direct++;
return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
});
await context.route('**/api/**', (route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const send = (status, body) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
const body = () => {
try {
return JSON.parse(req.postData() ?? '{}');
} catch {
return {};
}
};
const host = (serverId) => HOSTS[saved.find((s) => s.id === serverId)?.url ?? ''];
const view = (s) => ({ ...s });
if (path.includes('/auth/me')) return send(200, { user: { id: 1, email: 'check@example.com', name: 'check' } });
if (path.includes('/photos')) return send(200, { photos: [] });
if (path.includes('/recipes')) return send(200, { recipes: [] });
if (path === '/api/immich/config') {
if (req.method() === 'GET') {
return send(200, { ok: true, defaultUrl: 'https://photos.example.com', servers: saved.map(view) });
}
if (req.method() === 'DELETE') {
const at = saved.findIndex((s) => s.id === url.searchParams.get('id'));
if (at < 0) return send(404, { ok: false, error: 'invalid' });
saved.splice(at, 1);
return send(200, { ok: true, servers: saved.map(view) });
}
const b = body();
if (!b.url || !b.key) return send(400, { ok: false, error: 'invalid' });
const box = HOSTS[b.url];
if (!box) return send(200, { ok: false, error: 'unreachable' });
if (box.key !== b.key) return send(200, { ok: false, error: 'auth' });
const server = {
id: `s${++minted}`,
name: String(b.name ?? '').trim() || new URL(b.url).hostname,
url: b.url,
type: b.type === 'share' ? 'share' : 'api',
version: box.version,
canDownload: box.canDownload,
albums: cleanAlbums(b.albums),
keyMasked: `••••${String(b.key).slice(-4)}`,
};
saved.push(server);
return send(200, { ok: true, server: view(server) });
}
if (path === '/api/immich/probe') {
const b = body();
if (!b.url || !b.key) return send(400, { ok: false, error: 'invalid' });
const box = HOSTS[b.url];
if (!box) return send(200, { ok: false, error: 'unreachable' });
if (box.key !== b.key) return send(200, { ok: false, error: 'auth' });
return send(200, {
ok: true,
version: box.version,
albums: box.albums.map((id) => ({
id,
albumName: ALBUM_NAME[id],
assetCount: ASSETS.filter((a) => a.album === id).length,
coverId: null,
shared: false,
})),
canDownload: box.canDownload,
});
}
if (path === '/api/immich/albums') {
if (req.method() === 'PUT') {
const b = body();
const server = saved.find((s) => s.id === b.id);
if (!server) return send(404, { ok: false, error: 'invalid' });
server.albums = cleanAlbums(b.selected);
return send(200, { ok: true, albums: server.albums });
}
const box = host(url.searchParams.get('id') ?? '');
if (!box) return send(502, { ok: false, error: 'unreachable' });
return send(200, {
ok: true,
albums: box.albums.map((id) => ({
id,
albumName: ALBUM_NAME[id],
assetCount: ASSETS.filter((a) => a.album === id).length,
coverId: null,
shared: false,
})),
});
}
if (path === '/api/immich/assets') {
const box = host(url.searchParams.get('server') ?? '');
if (!box) return send(502, { ok: false, error: 'unreachable' });
const album = url.searchParams.get('album');
const page = Number(url.searchParams.get('page') ?? 1);
const size = Number(url.searchParams.get('size') ?? 200);
const rows = ASSETS.filter((a) => (album && album !== 'all' ? a.album === album : box.albums.includes(a.album)));
const items = rows.slice((page - 1) * size, page * size).map((a) => ({
id: a.id,
name: a.name,
takenAt: '2026-04-01T10:00:00.000Z',
width: 6000,
height: 4000,
size: 12345678,
}));
return send(200, { ok: true, page, size, total: rows.length, hasMore: items.length === size, items });
}
if (path === '/api/immich/thumb' || path === '/api/immich/original') {
const id = url.searchParams.get('id') ?? '';
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
return send(400, { ok: false, error: 'invalid' });
}
const box = host(url.searchParams.get('server') ?? '');
if (!box) return send(502, { ok: false, error: 'unreachable' });
if (path.endsWith('original') && !box.canDownload) return send(403, { ok: false, error: 'auth' });
if (path.endsWith('thumb')) thumbs++;
return route.fulfill({ status: 200, contentType: 'image/png', body: PNG });
}
return send(404, { ok: false, error: 'no' });
});
const page = await context.newPage();
await page.goto(`${base}/library`);
// The rows the column draws, and the number beside each name.
const counts = () =>
page.$$eval('[data-key^="lib-node-"]', (els) => {
const out = {};
for (const e of els) {
out[e.dataset.key.replace('lib-node-', '')] = (e.querySelector('.adm-album-count')?.textContent ?? '').trim();
}
return out;
});
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
const settle = (key, n) =>
page
.waitForFunction(
({ key, n }) => {
const row = document.querySelector(`[data-key="lib-node-${key}"]`);
return (row?.querySelector('.adm-album-count')?.textContent ?? '').trim() === String(n);
},
{ key, n },
{ timeout: 30_000, polling: 50 }
)
.catch(() => {});
const open = async (name) => {
// A node's albums are rows under it, and the column folds a folder away until
// it is opened — the same click a reader makes.
const row = `[data-key="lib-node-${name}"]`;
if ((await page.getAttribute(row, 'aria-expanded')) === 'false') await page.click(row);
await page.waitForTimeout(300);
};
const addDialog = async (url, key, share = false) => {
await page.click('[data-key="lib-add-immich"]');
await page.waitForSelector('[data-key="immich-url"]');
await page.fill('[data-key="immich-url"]', url);
await page.fill('[data-key="immich-key"]', key);
if (share) await page.check('[data-key="immich-share"]');
await page.click('[data-key="immich-check"]');
await page.waitForSelector('[data-key="immich-status"]');
};
const editDialog = async (node) => {
await page.click(`[data-key="lib-node-${node}"]`, { button: 'right' });
await page.waitForSelector(`[data-key="lib-immich-albums-${node}"]`);
await page.click(`[data-key="lib-immich-albums-${node}"]`);
await page.waitForSelector('[data-key="immich-ids"]');
// The albums are asked for live, so the list may land a moment after the form.
await page.waitForSelector('[data-key="immich-albums"]').catch(() => {});
};
// --- adding a server ---------------------------------------------------------
await page.waitForSelector('[data-key="lib-add-immich"]', { timeout: 60_000 });
await page.click('[data-key="lib-add-immich"]');
await page.waitForSelector('[data-key="immich-url"]');
step(
'the dialog opens with the address the deployment suggests already in it',
(await page.inputValue('[data-key="immich-url"]')) === 'https://photos.example.com',
await page.inputValue('[data-key="immich-url"]')
);
await page.fill('[data-key="immich-key"]', 'good-key');
await page.click('[data-key="immich-check"]');
await page.waitForSelector('[data-key="immich-status"]');
const saidOk = await page.$eval('[data-key="immich-status"]', (e) => !e.className.includes('err'));
step('a key that answers is said to be good', saidOk);
step(
'and what it offers is that server’s albums and no other server’s',
(await page.$('[data-key="immich-album-' + A1 + '"]')) !== null &&
(await page.$('[data-key="immich-album-' + A2 + '"]')) !== null &&
(await page.$('[data-key="immich-album-' + B1 + '"]')) === null
);
step(
'with every one of them ticked to begin with',
(await page.isChecked('[data-key="immich-album-' + A1 + '"]')) &&
(await page.isChecked('[data-key="immich-album-' + A2 + '"]'))
);
await page.click('[data-key="immich-save"]');
await settle('Immich', 5);
await open('Immich');
step(
'the server becomes a folder on the column, counted album by album',
same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }),
JSON.stringify(await counts())
);
step(
'and its node wears the source and the name it was given',
(await page.$eval('[data-key="lib-node-Immich"] .adm-album-name', (e) => e.textContent)) ===
'Immich · photos.example.com'
);
step('and its frames were tiled off the backend, not off Immich', thumbs > 0, `${thumbs} tiles`);
// --- an album unticked -------------------------------------------------------
await editDialog('Immich');
await page.uncheck('[data-key="immich-album-' + A2 + '"]');
await page.click('[data-key="immich-save"]');
await settle('Immich', 2);
await open('Immich');
step(
'an album unticked is an album the column stops drawing',
same(await counts(), { Immich: '2', 'Immich/A1': '2' }),
JSON.stringify(await counts())
);
// --- the album-ID field ------------------------------------------------------
// A whole address is an id, and a line that is not a uuid is nothing at all: the
// reader copies an album out of their browser and pastes what they got.
await editDialog('Immich');
await page.uncheck('[data-key="immich-album-' + A1 + '"]');
await page.fill('[data-key="immich-ids"]', `https://photos.example.com/albums/${A2}?x=1\nkhong-phai-uuid`);
await page.click('[data-key="immich-save"]');
await settle('Immich', 3);
await open('Immich');
step(
'an album pasted as a whole address is read as its id, and a line that is not one is dropped',
same(await counts(), { Immich: '3', 'Immich/A2': '3' }),
JSON.stringify(await counts())
);
// Nothing ticked and nothing typed is the empty state, which means every album
// the key can see — a decision, not a missing value.
await editDialog('Immich');
await page.uncheck('[data-key="immich-album-' + A2 + '"]');
await page.fill('[data-key="immich-ids"]', '');
await page.click('[data-key="immich-save"]');
await settle('Immich', 5);
await open('Immich');
step(
'nothing ticked means every album the key can see, not none of them',
same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }),
JSON.stringify(await counts())
);
// --- a share link ------------------------------------------------------------
await addDialog('https://share.example.com', 'share-xyz', true);
step('a share link is not offered an album picker: it is one album', (await page.$('[data-key="immich-albums"]')) === null);
await page.click('[data-key="immich-save"]');
await page.waitForSelector('[data-key="lib-node-Immich 2"]', { timeout: 30_000 });
await settle('Immich 2', 1);
step(
'and it becomes a second node beside the first, named by its own server',
(await page.$eval('[data-key="lib-node-Immich 2"] .adm-album-name', (e) => e.textContent)) ===
'Immich · share.example.com',
JSON.stringify(await counts())
);
await page.click('[data-key="lib-node-Immich 2"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-immich-sync-Immich 2"]');
step(
'and its menu carries no album picker',
(await page.$('[data-key="lib-immich-albums-Immich 2"]')) === null
);
step(
'nor a rescan, a rename or a tile job — those are the disk’s',
(await page.$('[data-key="lib-rescan-Immich 2"]')) === null &&
(await page.$('[data-key="lib-rename-Immich 2"]')) === null &&
(await page.$('[data-key="lib-thumbs-Immich 2"]')) === null
);
await page.keyboard.press('Escape');
// --- a key that does not work ------------------------------------------------
await addDialog('https://photos.example.com', 'not-the-key');
const saidBad = await page.$eval('[data-key="immich-status"]', (e) => e.className.includes('err'));
step('a key that is refused is said to be refused', saidBad);
await page.click('[data-key="immich-save"]');
await page.waitForTimeout(500);
const stillSaving = (await page.$('[data-key="immich-save"]')) !== null;
step(
'and a refused key is never a node: nothing is stored and the dialog stays open',
stillSaving && !('Immich 3' in (await counts())),
JSON.stringify(await counts())
);
await page.click('[data-key="immich-cancel"]');
// --- what must never happen --------------------------------------------------
step('the page never talks to Immich itself', direct === 0, `${direct} calls off-origin`);
await browser.close();
app.kill('SIGTERM');
console.log(failures ? `\n${failures} failed` : '\nall ok');
process.exit(failures ? 1 : 0);