e27998d820
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.
415 lines
17 KiB
JavaScript
415 lines
17 KiB
JavaScript
// 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);
|