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.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
// 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);
|
||||
+271
-44
@@ -9,6 +9,7 @@ import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDe
|
||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import {
|
||||
breathe,
|
||||
bySourceThenName,
|
||||
cachedFolders,
|
||||
cachedPhotos,
|
||||
canBrowseFolders,
|
||||
@@ -28,9 +29,11 @@ import {
|
||||
onLibraryBlocked,
|
||||
onThumbWritten,
|
||||
pickFolder,
|
||||
putFolder,
|
||||
readPhotoFile,
|
||||
readPhotos,
|
||||
reconnectPhotosForFolders,
|
||||
remoteUrl,
|
||||
removeFolder,
|
||||
renameFolder,
|
||||
scanAsked,
|
||||
@@ -56,6 +59,15 @@ import {
|
||||
restoreNow,
|
||||
type BackupStatus,
|
||||
} from './engine/libraryBackup';
|
||||
import {
|
||||
addImmichFolder,
|
||||
forgetImmichServer,
|
||||
immichConfig,
|
||||
saveImmichAlbums,
|
||||
syncImmich,
|
||||
type ImmichServer,
|
||||
} from './engine/immich';
|
||||
import { ImmichDialog } from './ui/ImmichDialog';
|
||||
|
||||
// The catalogue screen, laid out like the admin's picture manager: the tree of
|
||||
// folders and subfolders down the left, the frame that is up in the middle, and
|
||||
@@ -319,6 +331,17 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [folders, setFolders] = useState<LibraryFolder[]>(() => cachedFolders());
|
||||
const [ready, setReady] = useState<Set<string>>(new Set());
|
||||
// The Immich servers this account has saved, and the two dialogs that write
|
||||
// them: one adding a server, one changing which albums an existing node keeps.
|
||||
// A server is not a folder on the disk — see `engine/immich.ts` — so it is the
|
||||
// column's second source and the page holds no key of its own.
|
||||
const [servers, setServers] = useState<ImmichServer[]>([]);
|
||||
// The address this deployment suggests for a first server — `IMMICH_URL`, which
|
||||
// the backend answers with. Offered as a filled-in field, not as a saved row:
|
||||
// it is a suggestion until a key is typed against it.
|
||||
const [immichUrl, setImmichUrl] = useState('');
|
||||
const [addImmich, setAddImmich] = useState(false);
|
||||
const [editAlbums, setEditAlbums] = useState<LibraryFolder | null>(null);
|
||||
// The strip starts on the catalogue the last screen read: coming back from the
|
||||
// studio is a screen opening, not a page loading, and the frames the reader was
|
||||
// looking at are already in hand. The read below replaces them a moment later.
|
||||
@@ -759,18 +782,27 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
setFolders(rows);
|
||||
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
|
||||
|
||||
// 3. Query directory permissions in parallel
|
||||
// 3. Query directory permissions in parallel. A folder that is really an
|
||||
// Immich server has no handle to hand back — its key lives on the backend —
|
||||
// so it is ready by definition, and the column draws it beside the drives.
|
||||
const permResults = await Promise.all(
|
||||
rows.map(async (folder) => ({
|
||||
name: folder.name,
|
||||
ok: await ensurePermission(folder.handle),
|
||||
ok: folder.source === 'immich' ? true : folder.handle ? await ensurePermission(folder.handle) : false,
|
||||
}))
|
||||
);
|
||||
if (!alive) return;
|
||||
const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name));
|
||||
setReady(ok);
|
||||
|
||||
// 4. Await photos reload if still running
|
||||
// 4. The saved Immich servers, which is what the column needs to know
|
||||
// whether the second source has anything in it at all.
|
||||
const config = await immichConfig();
|
||||
if (!alive) return;
|
||||
setServers(config.servers);
|
||||
setImmichUrl(config.defaultUrl);
|
||||
|
||||
// 5. Await photos reload if still running
|
||||
await reloadPromise;
|
||||
if (!alive) return;
|
||||
setLoaded(true);
|
||||
@@ -829,6 +861,26 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
};
|
||||
}, [menu, photoMenu]);
|
||||
|
||||
// Read a server's node into the catalogue. This is to a server what a reading is
|
||||
// to a roll: the rows and the column first, the tiles after, and it is
|
||||
// `engine/immich.ts` that does the work. The node's album rows are put in the
|
||||
// place of its old ones — an album the reader unticked is a row that has to go,
|
||||
// and `reload` only ever adds.
|
||||
const syncServer = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
setNote(null);
|
||||
try {
|
||||
const res = await syncImmich(folder);
|
||||
setDirs((prev) => [...prev.filter((d) => d.folder !== folder.name), ...res.dirs]);
|
||||
setNote(t('lib.immich.added', { n: res.total, folder: folder.label ?? folder.name }));
|
||||
await reload();
|
||||
} catch {
|
||||
setNote(t('lib.immich.failed'));
|
||||
}
|
||||
},
|
||||
[reload, t]
|
||||
);
|
||||
|
||||
// A reading asked for here is a request, not a reading: one walk runs at a time,
|
||||
// and a folder asked for while another is being read waits its turn — which is
|
||||
// what makes a right click on four folders mean four readings rather than three
|
||||
@@ -836,6 +888,10 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// for, and a request called off before it ran says nothing at all.
|
||||
const scan = useCallback(
|
||||
async (folder: LibraryFolder, quiet = false, from = '', mode: ScanMode = 'update') => {
|
||||
// A server's node is not walked: its frames are read by `syncServer`, over
|
||||
// the backend's own routes. Nothing here may hand a handle-less folder to a
|
||||
// walk that would take it for a folder of the disk.
|
||||
if (folder.source === 'immich') return;
|
||||
// A quiet reading is one nobody asked to hear about — the repair's own,
|
||||
// whose note says the whole of what it is doing — so it leaves the note
|
||||
// where it found it. A loud one clears it: its word is the one that lands.
|
||||
@@ -865,11 +921,15 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// it. Frames that have not moved are skipped on their size and their time, so
|
||||
// the cost is the disk's own listing and not a develop a frame.
|
||||
const repair = useCallback(async () => {
|
||||
if (!folders.length) return;
|
||||
if (!window.confirm(t('lib.repairConfirm', { n: folders.length }))) return;
|
||||
setNote(t('lib.repairRun', { n: folders.length }));
|
||||
// The repair is of the disk: a repair reads every roll back to the disk, and a
|
||||
// server has no disk here to be read back to. Its own word is `ĐỌC LẠI` on
|
||||
// its row.
|
||||
const rolls = folders.filter((f) => !f.source);
|
||||
if (!rolls.length) return;
|
||||
if (!window.confirm(t('lib.repairConfirm', { n: rolls.length }))) return;
|
||||
setNote(t('lib.repairRun', { n: rolls.length }));
|
||||
let done = 0;
|
||||
for (const folder of folders) {
|
||||
for (const folder of rolls) {
|
||||
// One roll's failure is one roll's: a handle the browser will not hand
|
||||
// back, a drive that is not there — the rest of the repair still runs.
|
||||
try {
|
||||
@@ -917,6 +977,9 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// again every time the tab is raised is the walk this screen just gave up.
|
||||
if (busy || scanSession() || resume) return;
|
||||
for (const folder of folders) {
|
||||
// A server has no reading to be cut off: what it was left at is one
|
||||
// round trip away, and there is no walk here for this chip to offer.
|
||||
if (folder.source) continue;
|
||||
if (!ready.has(folder.name)) continue;
|
||||
if (await unfinished(folder.name)) {
|
||||
setResume(folder.name);
|
||||
@@ -937,9 +1000,10 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
setNote(null);
|
||||
try {
|
||||
const folder = await pickFolder();
|
||||
if (!folder) return;
|
||||
if (!folder?.handle) return;
|
||||
const handle = folder.handle;
|
||||
|
||||
const relInfo = await findFolderRelationship(folder.handle, folders);
|
||||
const relInfo = await findFolderRelationship(handle, folders);
|
||||
|
||||
if (relInfo.type === 'subfolder') {
|
||||
const { parent, relPath } = relInfo;
|
||||
@@ -966,16 +1030,14 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
await scan(parent, false, relPath + '/');
|
||||
} else if (relInfo.type === 'parent') {
|
||||
const { child } = relInfo;
|
||||
const nextFolders = [...folders.filter((f) => f.name !== child.name), folder].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const nextFolders = [...folders.filter((f) => f.name !== child.name), folder].sort(bySourceThenName);
|
||||
setFolders(nextFolders);
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
setNode(folder.name);
|
||||
await scan(folder);
|
||||
} else {
|
||||
setFolders((prev) =>
|
||||
[...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name))
|
||||
[...prev.filter((f) => f.name !== folder.name), folder].sort(bySourceThenName)
|
||||
);
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
setNode(folder.name);
|
||||
@@ -989,9 +1051,13 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
|
||||
const reconnect = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
if (await ensurePermission(folder.handle)) {
|
||||
// Only a folder of the disk has a permission to be handed back: a server's
|
||||
// key lives on the backend, and this row never offers this action.
|
||||
const handle = folder.handle;
|
||||
if (!handle) return;
|
||||
if (await ensurePermission(handle)) {
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
await reconnectPhotosForFolders(folder.handle);
|
||||
await reconnectPhotosForFolders(handle);
|
||||
await scan(folder);
|
||||
} else {
|
||||
setNote(t('lib.failed'));
|
||||
@@ -1027,6 +1093,51 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
[folders, reload]
|
||||
);
|
||||
|
||||
// A server that has just been saved, or one whose albums have been reticked. A
|
||||
// new server becomes a folder on the column the moment it is saved: nothing
|
||||
// walks it, so there is nothing to wait for. An existing node keeps its name and
|
||||
// its frames, which the sync below reads again for the albums it now keeps.
|
||||
const serverSaved = useCallback(
|
||||
async (saved: ImmichServer, isNew: boolean) => {
|
||||
setAddImmich(false);
|
||||
const edited = editAlbums;
|
||||
setEditAlbums(null);
|
||||
// The saved list is read back rather than patched: the backend is what
|
||||
// holds the key, and what it says is what the dialog will show next time.
|
||||
const config = await immichConfig(true);
|
||||
setServers(config.servers);
|
||||
setImmichUrl(config.defaultUrl);
|
||||
if (!isNew && edited) {
|
||||
const next: LibraryFolder = { ...edited, albums: saved.albums };
|
||||
await putFolder(next);
|
||||
setFolders((prev) => prev.map((f) => (f.name === next.name ? next : f)));
|
||||
await syncServer(next);
|
||||
return;
|
||||
}
|
||||
const current = await listFolders();
|
||||
const folder = await addImmichFolder(saved, current.map((f) => f.name));
|
||||
setFolders([...current, folder].sort(bySourceThenName));
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
setNode(folder.name);
|
||||
await syncServer(folder);
|
||||
},
|
||||
[editAlbums, syncServer]
|
||||
);
|
||||
|
||||
// Ngắt kết nối: the key goes and the node goes with it — the address and the
|
||||
// key are the account's, and a node with neither to read by is a row that could
|
||||
// only fail. "Xoá khỏi thư viện" (`drop`) is the other half: the row goes and
|
||||
// the server is left saved, so adding it back is the same address and no key at
|
||||
// all.
|
||||
const disconnect = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
if (folder.server) await forgetImmichServer(folder.server);
|
||||
await drop(folder);
|
||||
setServers((await immichConfig(true)).servers);
|
||||
},
|
||||
[drop]
|
||||
);
|
||||
|
||||
// The frame goes to the studio through the address bar: the studio owns the
|
||||
// load (develop, EXIF, session) and this page owns no part of it. While a scan
|
||||
// is running the address bar is the one thing that changes — `go` keeps the
|
||||
@@ -1829,11 +1940,12 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
const [stageFor, setStageFor] = useState<string | null>(null);
|
||||
const stageUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Only an object URL this page made is this page's to drop: a server's preview
|
||||
// is an address the browser owns, and revoking it would be revoking nothing.
|
||||
const revokeStageUrl = () => {
|
||||
if (stageUrlRef.current) {
|
||||
URL.revokeObjectURL(stageUrlRef.current);
|
||||
stageUrlRef.current = null;
|
||||
}
|
||||
const url = stageUrlRef.current;
|
||||
stageUrlRef.current = null;
|
||||
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Load high-resolution photo/preview for the raised stage frame directly from local file handle
|
||||
@@ -1843,6 +1955,17 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
setStageFor(null);
|
||||
if (!frame) return;
|
||||
|
||||
// A server's frame is not read off a disk: the stage draws the preview Immich
|
||||
// already keeps, a screen-sized copy, rather than pulling the original through
|
||||
// the proxy for a picture on a screen. A frame with no right to download has no
|
||||
// original to fetch at all, and this is the road it is viewed by.
|
||||
if (frame.remoteId) {
|
||||
stageUrlRef.current = remoteUrl('preview', frame);
|
||||
setStageUrl(stageUrlRef.current);
|
||||
setStageFor(frame.id);
|
||||
return;
|
||||
}
|
||||
|
||||
let live = true;
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -1953,7 +2076,7 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
|
||||
{note ? <p className="hint adm-note">{note}</p> : null}
|
||||
|
||||
{canBrowseFolders() ? (
|
||||
{canBrowseFolders() || servers.length ? (
|
||||
<div className="adm-frame" data-key="lib-frame">
|
||||
<div className="adm-pane">
|
||||
{/* One toolbar for the whole screen: add, the scan in flight, and the
|
||||
@@ -1961,8 +2084,22 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
same row and ellipsises — the catalogue is the content, this bar
|
||||
is furniture. */}
|
||||
<div className="chip-row lib-bar">
|
||||
<button type="button" className="btn primary" data-key="lib-add" onClick={() => void add()}>
|
||||
{t('lib.add')}
|
||||
{canBrowseFolders() ? (
|
||||
<button type="button" className="btn primary" data-key="lib-add" onClick={() => void add()}>
|
||||
{t('lib.add')}
|
||||
</button>
|
||||
) : null}
|
||||
{/* The second source: a server the account keeps, read through the
|
||||
backend's own routes. It sits beside ADD FOLDER because that is
|
||||
what it is to the reader — one more place to look — and it is
|
||||
offered whether or not this browser can open a folder at all. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-add-immich"
|
||||
onClick={() => setAddImmich(true)}
|
||||
>
|
||||
{t('lib.immich.add')}
|
||||
</button>
|
||||
{live || scanQueue().length ? (
|
||||
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
|
||||
@@ -1972,25 +2109,31 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
{/* The catalogue's own copy: one button that picks the folder the
|
||||
first time and writes into it every time after, and one that
|
||||
reads it back. Both are furniture next to the reading itself,
|
||||
so they sit behind the hint rather than in front of it. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-backup"
|
||||
disabled={backBusy}
|
||||
onClick={() => void backup()}
|
||||
>
|
||||
{t('lib.backup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-restore"
|
||||
disabled={backBusy}
|
||||
onClick={() => void restore()}
|
||||
>
|
||||
{t('lib.restore')}
|
||||
</button>
|
||||
so they sit behind the hint rather than in front of it — and
|
||||
both are the disk's, so neither is offered where no folder can
|
||||
be picked. */}
|
||||
{canBrowseFolders() ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-backup"
|
||||
disabled={backBusy}
|
||||
onClick={() => void backup()}
|
||||
>
|
||||
{t('lib.backup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-restore"
|
||||
disabled={backBusy}
|
||||
onClick={() => void restore()}
|
||||
>
|
||||
{t('lib.restore')}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{/* The repair, for the account the API calls an admin: the catalogue
|
||||
this screen draws is the browser's own, and a catalogue that has
|
||||
drifted — counts under folders the disk emptied, rows for frames
|
||||
@@ -2169,7 +2312,7 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
aria-current={node === n.key}
|
||||
aria-busy={scanning}
|
||||
aria-expanded={under ? expanded.has(n.key) : undefined}
|
||||
title={t('lib.nodeHint')}
|
||||
title={folder?.source === 'immich' ? t('lib.immich.hint') : t('lib.nodeHint')}
|
||||
style={{ paddingLeft: 9 + n.depth * 12 }}
|
||||
// A row with children opens on the folder and folds it in
|
||||
// the same click: the frames below stay in the strip, only
|
||||
@@ -2614,7 +2757,16 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="adm-note hint">{t('lib.unsupported')}</p>
|
||||
<>
|
||||
<p className="adm-note hint">{t('lib.unsupported')}</p>
|
||||
{/* The second source is not the disk's and is not refused by a browser
|
||||
that will not hand a page a folder: a server is one key, and every
|
||||
browser can hold one. So it stands here too, outside the gate the
|
||||
folder's own controls live behind. */}
|
||||
<button type="button" className="btn" data-key="lib-add-immich" onClick={() => setAddImmich(true)}>
|
||||
{t('lib.immich.add')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The context menu: a folder gets what belongs to a folder rather than to a
|
||||
@@ -2644,7 +2796,63 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
</button>
|
||||
) : null}
|
||||
{menu.folder ? (
|
||||
(ready.has(menu.folder.name) || ready.has((menu.folder.name || '').replace(/\\/g, '/'))) ? (
|
||||
menu.folder.source === 'immich' ? (
|
||||
<>
|
||||
{/* A server's node carries the server's own four things: read it
|
||||
again, change what it keeps, drop the key, or leave the row — and
|
||||
never a rescan, a rename or a tile job, which are the disk's. */}
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-immich-sync-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void syncServer(menu.folder!);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">↻</span>
|
||||
{t('lib.immich.sync')}
|
||||
</button>
|
||||
{menu.folder.kind !== 'share' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-immich-albums-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setEditAlbums(menu.folder!);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">▤</span>
|
||||
{t('lib.immich.albumsChoose')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-immich-off-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void disconnect(menu.folder!);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">⚿</span>
|
||||
{t('lib.immich.disconnect')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-drop-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void drop(menu.folder!);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">✕</span>
|
||||
{t('lib.immich.keep')}
|
||||
</button>
|
||||
</>
|
||||
) : (ready.has(menu.folder.name) || ready.has((menu.folder.name || '').replace(/\\/g, '/'))) ? (
|
||||
<>
|
||||
{!menu.root ? (
|
||||
<button
|
||||
@@ -2909,6 +3117,25 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The two dialogs of the second source: one adding a server, one changing
|
||||
what an existing node keeps. They are the only place a key is typed, and
|
||||
it goes straight up to the backend, which probes it before it stores. */}
|
||||
{addImmich ? (
|
||||
<ImmichDialog
|
||||
defaultUrl={immichUrl}
|
||||
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
|
||||
onClose={() => setAddImmich(false)}
|
||||
/>
|
||||
) : null}
|
||||
{editAlbums ? (
|
||||
<ImmichDialog
|
||||
server={servers.find((s) => s.id === editAlbums.server) ?? null}
|
||||
defaultUrl=""
|
||||
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
|
||||
onClose={() => setEditAlbums(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// Immich, as a second photo source beside the folders on disk — the page's half.
|
||||
//
|
||||
// Nothing here talks to Immich: the key never reaches the browser, and the
|
||||
// address never leaves the backend, so every call below is one of the routes in
|
||||
// `backend/src/immich.ts` on this page's own origin. What this file does is turn
|
||||
// a server's answer into the two shapes the LIBRARY already knows — a row in the
|
||||
// `photos` store and a name in the column — so the tree, the wall and the strip
|
||||
// draw a server's frames with the code they already had for a folder's.
|
||||
import {
|
||||
breathe,
|
||||
missingThumbs,
|
||||
normPath,
|
||||
prunePhotos,
|
||||
putFolder,
|
||||
putPhotoThumb,
|
||||
putPhotos,
|
||||
remoteUrl,
|
||||
saveWalkedDirs,
|
||||
type LibraryDir,
|
||||
type LibraryFolder,
|
||||
type LibraryPhoto,
|
||||
} from './library';
|
||||
|
||||
// Immich answers 200 items at most for one page of a search (the backend caps it
|
||||
// there), so this is the size the sync loops at: one round trip per two hundred
|
||||
// frames rather than one per frame.
|
||||
const PAGE = 200;
|
||||
// Tiles are drawn after the rows have landed rather than before: the column and
|
||||
// the count are what the reader sees first, and a tile is the wall's own business.
|
||||
const TILE_LANES = 3;
|
||||
|
||||
export interface ImmichAlbum {
|
||||
id: string;
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
coverId: string | null;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
export interface ImmichAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
takenAt: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface ImmichServer {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'api' | 'share';
|
||||
version: string | null;
|
||||
canDownload: boolean | null;
|
||||
albums: string[];
|
||||
keyMasked: string;
|
||||
}
|
||||
|
||||
// The three refusals the dialog can say something about, plus the one it can only
|
||||
// say as a shape: the key is wrong or too narrow, the box cannot be reached, it
|
||||
// answered with something unusable, or what was typed is not an address and a key.
|
||||
export type ImmichError = 'auth' | 'unreachable' | 'server' | 'invalid';
|
||||
|
||||
export type ImmichProbe =
|
||||
| { ok: true; version: string | null; albums: ImmichAlbum[]; canDownload: boolean | null }
|
||||
| { ok: false; error: ImmichError };
|
||||
|
||||
export type ImmichSaved = { ok: true; server: ImmichServer } | { ok: false; error: ImmichError };
|
||||
|
||||
// One place reads a JSON answer, so a route that answered with a page of HTML —
|
||||
// a proxy in the way, a box that is not Immich — is a refusal rather than a crash
|
||||
// somewhere further in.
|
||||
async function json<T>(res: Response): Promise<T | null> {
|
||||
try {
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
return await json<T>(await fetch(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The saved servers, plus the address this deployment suggests for the first one.
|
||||
// One call per session: the tree has to draw on the first paint, and a server
|
||||
// list that is asked for on every render is a round trip per render.
|
||||
let configOnce: Promise<{ defaultUrl: string; servers: ImmichServer[] }> | null = null;
|
||||
|
||||
export function immichConfig(fresh = false): Promise<{ defaultUrl: string; servers: ImmichServer[] }> {
|
||||
if (fresh || !configOnce) {
|
||||
configOnce = get<{ defaultUrl?: string; servers?: ImmichServer[] }>('/api/immich/config').then((body) =>
|
||||
body ? { defaultUrl: body.defaultUrl ?? '', servers: body.servers ?? [] } : { defaultUrl: '', servers: [] }
|
||||
);
|
||||
}
|
||||
return configOnce;
|
||||
}
|
||||
|
||||
export function forgetImmichConfig(): void {
|
||||
configOnce = null;
|
||||
}
|
||||
|
||||
// "Check this key" — nothing is stored, so a typo never becomes a row.
|
||||
export async function probeImmich(url: string, key: string, type: 'api' | 'share'): Promise<ImmichProbe> {
|
||||
try {
|
||||
const res = await fetch('/api/immich/probe', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ url, key, type }),
|
||||
});
|
||||
const body = await json<ImmichProbe>(res);
|
||||
return body ?? { ok: false, error: 'server' };
|
||||
} catch {
|
||||
return { ok: false, error: 'unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
// Add a server, or change one. The backend probes before it stores, so a server
|
||||
// that comes back saved is one that answered — `albums` is the ticked set, sent
|
||||
// on the same call that saves the key so a fresh server is never briefly "all".
|
||||
export async function saveImmichServer(input: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
key?: string;
|
||||
type?: 'api' | 'share';
|
||||
albums?: string[];
|
||||
}): Promise<ImmichSaved> {
|
||||
try {
|
||||
const res = await fetch('/api/immich/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await json<ImmichSaved>(res);
|
||||
if (body?.ok) forgetImmichConfig();
|
||||
return body ?? { ok: false, error: 'server' };
|
||||
} catch {
|
||||
return { ok: false, error: 'unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function forgetImmichServer(id: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/immich/config?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (res.ok) forgetImmichConfig();
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The albums this key can see, asked for live: an album added on Immich has to be
|
||||
// there the next time the dialog is opened, so nothing here is cached.
|
||||
export async function listImmichAlbums(server: string): Promise<ImmichAlbum[]> {
|
||||
const body = await get<{ ok?: boolean; albums?: ImmichAlbum[] }>(
|
||||
`/api/immich/albums?id=${encodeURIComponent(server)}`
|
||||
);
|
||||
return body?.albums ?? [];
|
||||
}
|
||||
|
||||
export async function saveImmichAlbums(server: string, selected: string[]): Promise<string[]> {
|
||||
try {
|
||||
const res = await fetch('/api/immich/albums', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: server, selected }),
|
||||
});
|
||||
const body = await json<{ albums?: string[] }>(res);
|
||||
return body?.albums ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// One page of a server's frames. `album` is an Immich id or null for everything
|
||||
// the key can see — the album field's empty state.
|
||||
async function listAssets(
|
||||
server: string,
|
||||
album: string | null,
|
||||
page: number
|
||||
): Promise<{ items: ImmichAsset[]; hasMore: boolean }> {
|
||||
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
|
||||
if (album) query.set('album', album);
|
||||
const body = await get<{ items?: ImmichAsset[]; hasMore?: boolean }>(`/api/immich/assets?${query.toString()}`);
|
||||
return { items: body?.items ?? [], hasMore: body?.hasMore === true };
|
||||
}
|
||||
|
||||
// What the tree paints, and the name an album's frames are filed under. An album
|
||||
// with no name — a key too narrow to read one, an album the reader never titled —
|
||||
// is known by the first of its id, which is what the plan asks for.
|
||||
function albumLabels(albums: ImmichAlbum[]): Map<string, string> {
|
||||
const taken = new Set<string>();
|
||||
const out = new Map<string, string>();
|
||||
for (const album of albums) {
|
||||
let label = album.albumName.trim() || album.id.slice(0, 8);
|
||||
// Two albums may carry one name — Immich allows it — and a column is drawn
|
||||
// off the path, so a second of that name would be a row the first one already
|
||||
// is. The uuid is what tells them apart, and eight of it is enough to read.
|
||||
if (taken.has(label)) label = `${label} ~${album.id.slice(0, 8)}`;
|
||||
taken.add(label);
|
||||
out.set(album.id, label);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The `~` suffix a frame takes when an album holds two files of one name: the id
|
||||
// is the name, so one of them has to move rather than one of them disappear.
|
||||
function frameName(name: string, id: string, taken: Set<string>): string {
|
||||
if (!taken.has(name)) return name;
|
||||
return `${name} ~${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export interface ImmichSyncResult {
|
||||
total: number;
|
||||
albums: number;
|
||||
// The album rows this read named, spelled the way the column spells a path. The
|
||||
// screen puts these in the place of the node's old ones: an album the reader
|
||||
// unticked is a row that has to go, and a record that is only ever added to
|
||||
// would keep drawing it.
|
||||
dirs: LibraryDir[];
|
||||
}
|
||||
|
||||
// Read a server's albums into the catalogue: the rows and the column first, so
|
||||
// the count and the tree are right the moment this returns, and the tiles after,
|
||||
// a lane at a time, so a reader who opens the node while it is still fetching
|
||||
// sees frames rather than a blank wall.
|
||||
//
|
||||
// One read covers the whole node. There is no per-album read: the album list is
|
||||
// one call, a page of frames is one call, and a column that says one album is
|
||||
// fresh while the others are a week old is a column nobody can read a number off.
|
||||
export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResult> {
|
||||
const server = folder.server ?? '';
|
||||
const albums = await listImmichAlbums(server);
|
||||
const labels = albumLabels(albums);
|
||||
const chosen = folder.albums ?? [];
|
||||
// An empty tick list is a decision and not a missing value: it means every album
|
||||
// this key can see (see the plan, the album-ID field).
|
||||
const wanted = albums.filter((a) => chosen.length === 0 || chosen.includes(a.id));
|
||||
|
||||
const rows: LibraryPhoto[] = [];
|
||||
const dirs: LibraryDir[] = [];
|
||||
const counts: Record<string, number> = {};
|
||||
const root = normPath(folder.name);
|
||||
|
||||
for (const album of wanted) {
|
||||
const label = labels.get(album.id) ?? album.id.slice(0, 8);
|
||||
// The names met in this one album. Two files of one name are two files, and
|
||||
// the id is the name, so the second of them moves rather than disappears.
|
||||
const taken = new Set<string>();
|
||||
let page = 1;
|
||||
let count = 0;
|
||||
for (;;) {
|
||||
const { items, hasMore } = await listAssets(server, album.id, page);
|
||||
for (const item of items) {
|
||||
if (!item.id || !item.name) continue;
|
||||
const name = frameName(item.name, item.id, taken);
|
||||
taken.add(item.name);
|
||||
rows.push({
|
||||
id: `${root}/${label}/${name}`,
|
||||
folder: folder.name,
|
||||
dir: label,
|
||||
name,
|
||||
server,
|
||||
remoteId: item.id,
|
||||
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
|
||||
size: item.size ?? 0,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
count++;
|
||||
}
|
||||
if (!hasMore || !items.length) break;
|
||||
page++;
|
||||
}
|
||||
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
|
||||
counts[`${root}/${label}`] = count;
|
||||
}
|
||||
counts[root] = rows.length;
|
||||
|
||||
await saveWalkedDirs(root, dirs, counts, true);
|
||||
// A frame the server no longer lists is not a frame to keep drawing: the row,
|
||||
// its tile and its marks go, exactly as a frame taken off the disk does. This
|
||||
// read saw the whole node, so it is the one allowed to say it.
|
||||
const met = new Set(rows.map((r) => `${normPath(r.dir)}/${r.name}`.toLowerCase()));
|
||||
await prunePhotos(root, '', met);
|
||||
await putPhotos(rows);
|
||||
|
||||
// And the tiles, for the frames that have none. A frame whose tile is already
|
||||
// kept is not asked for again, so the second sync of a roll costs the rows.
|
||||
const missing = new Set(await missingThumbs(rows.map((r) => r.id)));
|
||||
const queue = rows.filter((r) => missing.has(r.id));
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(TILE_LANES, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const row = queue.shift()!;
|
||||
try {
|
||||
const res = await fetch(remoteUrl('thumbnail', row));
|
||||
if (res.ok) await putPhotoThumb(row.id, await res.blob());
|
||||
} catch {
|
||||
// One frame's tile is one frame's: the wall draws a blank cell and the
|
||||
// reader may ask again, rather than a sync that stops halfway.
|
||||
}
|
||||
await breathe();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { total: rows.length, albums: wanted.length, dirs };
|
||||
}
|
||||
|
||||
// The name a server's node takes: the first one is `Immich`, and a second is
|
||||
// numbered, because the name is the store's key and two keys cannot be one.
|
||||
export function immichFolderName(taken: Iterable<string>): string {
|
||||
const used = new Set(taken);
|
||||
let n = 1;
|
||||
while (used.has(n === 1 ? 'Immich' : `Immich ${n}`)) n++;
|
||||
return n === 1 ? 'Immich' : `Immich ${n}`;
|
||||
}
|
||||
|
||||
// What the column paints for that node: the source and the name its owner gave it.
|
||||
export function immichFolderLabel(serverName: string): string {
|
||||
return `Immich · ${serverName}`;
|
||||
}
|
||||
|
||||
// Put a saved server in the column as a folder. The row is the page's own — a
|
||||
// name, the server it reads, and the albums it keeps — and `taken` is what it must
|
||||
// not collide with, because the name is the store's key and two keys cannot be
|
||||
// one. The name is `Immich`, `Immich 2`, …; the label is what the tree paints.
|
||||
export async function addImmichFolder(server: ImmichServer, taken: Iterable<string>): Promise<LibraryFolder> {
|
||||
const folder: LibraryFolder = {
|
||||
name: immichFolderName(taken),
|
||||
label: immichFolderLabel(server.name),
|
||||
source: 'immich',
|
||||
server: server.id,
|
||||
albums: server.albums,
|
||||
kind: server.type === 'share' ? 'share' : 'album',
|
||||
};
|
||||
await putFolder(folder);
|
||||
return folder;
|
||||
}
|
||||
@@ -79,7 +79,24 @@ export interface LibraryFolder {
|
||||
name: string;
|
||||
// What the tree paints instead, when the visitor has renamed the folder.
|
||||
label?: string;
|
||||
handle: FileSystemDirectoryHandle;
|
||||
// A folder of the disk has one. A folder that is really a server — the LIBRARY's
|
||||
// Immich node — has none, and every frame under it is read over the network by
|
||||
// the routes in `immich.ts` instead of off a handle.
|
||||
handle?: FileSystemDirectoryHandle;
|
||||
// Set on a node whose frames are an Immich server's, absent on the disk's. A
|
||||
// node is one of these or the other and never both: nothing on this screen
|
||||
// offers a disk action — a rescan, a permission — on a server, and nothing
|
||||
// offers a server action on a folder.
|
||||
source?: 'immich';
|
||||
// The saved server this node reads, by the id the backend minted. Never an
|
||||
// address: an address only ever goes up once, when one is added.
|
||||
server?: string;
|
||||
// The albums this node keeps, by Immich's own id. Absent or empty is the album
|
||||
// field's empty state — every album the key can see.
|
||||
albums?: string[];
|
||||
// 'album' for a node built from album names, 'share' for one built from a share
|
||||
// link: a share link *is* one album, so it has no picker and no "Người".
|
||||
kind?: 'album' | 'share';
|
||||
}
|
||||
|
||||
export interface LibraryPhoto {
|
||||
@@ -91,7 +108,12 @@ export interface LibraryPhoto {
|
||||
// This is what the tree column reads.
|
||||
dir: string;
|
||||
name: string;
|
||||
handle: FileSystemFileHandle;
|
||||
handle?: FileSystemFileHandle;
|
||||
// The server and Immich's own uuid for this frame, on a frame whose bytes are a
|
||||
// server's rather than the disk's. Both absent together: the uuid is what every
|
||||
// route above reads the frame by, and the server says which key fetches it.
|
||||
remoteId?: string;
|
||||
server?: string;
|
||||
// The tile the catalogue kept of this frame, where one has been made: it is
|
||||
// made when the frame is first drawn and not when it is found, so a frame just
|
||||
// read has none. Null is the same as absent — a tile that could not be made.
|
||||
@@ -277,6 +299,7 @@ export async function findFolderRelationship(
|
||||
| { type: 'root' }
|
||||
> {
|
||||
for (const existing of existingFolders) {
|
||||
if (!existing.handle) continue;
|
||||
try {
|
||||
const relParts = await existing.handle.resolve(newHandle);
|
||||
if (relParts && relParts.length > 0) {
|
||||
@@ -286,6 +309,7 @@ export async function findFolderRelationship(
|
||||
}
|
||||
|
||||
for (const existing of existingFolders) {
|
||||
if (!existing.handle) continue;
|
||||
try {
|
||||
const relParts = await newHandle.resolve(existing.handle);
|
||||
if (relParts && relParts.length > 0) {
|
||||
@@ -314,6 +338,14 @@ function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore
|
||||
|
||||
let cachedFoldersList: LibraryFolder[] | null = null;
|
||||
|
||||
// A server is not a folder on a disk, and a reader who has just added one is
|
||||
// looking for it at the top of the column rather than among ten drives in the
|
||||
// order of their names. The name is what tells two of either apart, so it is the
|
||||
// only other thing this orders by.
|
||||
export function bySourceThenName(a: LibraryFolder, b: LibraryFolder): number {
|
||||
return (a.source ? 0 : 1) - (b.source ? 0 : 1) || a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
export function cachedFolders(): LibraryFolder[] {
|
||||
return cachedFoldersList ?? [];
|
||||
}
|
||||
@@ -321,7 +353,7 @@ export function cachedFolders(): LibraryFolder[] {
|
||||
export async function listFolders(): Promise<LibraryFolder[]> {
|
||||
try {
|
||||
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
|
||||
const sorted = rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = rows.sort(bySourceThenName);
|
||||
cachedFoldersList = sorted;
|
||||
return sorted;
|
||||
} catch {
|
||||
@@ -341,8 +373,8 @@ export async function reconnectPhotosForFolders(
|
||||
folderTargets.push({ name: newFolderHandle.name, handle: newFolderHandle });
|
||||
|
||||
const knownFolderNames = new Set([
|
||||
...existingFolders.map((f) => f.name),
|
||||
...allPhotos.map((p) => p.folder),
|
||||
...existingFolders.filter((f) => !f.source).map((f) => f.name),
|
||||
...allPhotos.filter((p) => !p.remoteId).map((p) => p.folder),
|
||||
]);
|
||||
|
||||
for (const name of knownFolderNames) {
|
||||
@@ -369,7 +401,7 @@ export async function reconnectPhotosForFolders(
|
||||
|
||||
const photosToUpdate: LibraryPhoto[] = [];
|
||||
for (const target of folderTargets) {
|
||||
const matchingPhotos = allPhotos.filter((p) => p.folder === target.name && !p.handle);
|
||||
const matchingPhotos = allPhotos.filter((p) => p.folder === target.name && !p.handle && !p.remoteId);
|
||||
if (!matchingPhotos.length) continue;
|
||||
|
||||
const dirCache = new Map<string, FileSystemDirectoryHandle>();
|
||||
@@ -422,6 +454,15 @@ export async function pickFolder(): Promise<LibraryFolder | null> {
|
||||
return folder;
|
||||
}
|
||||
|
||||
// A row that is not a folder on the disk — the LIBRARY's Immich node — is put in
|
||||
// the same store under the same key, so the column draws it beside the drives and
|
||||
// finds it again on the next visit. It is the page's own row: nothing here needs a
|
||||
// handle to hold it, and every frame under it is read by `immich.ts`.
|
||||
export async function putFolder(folder: LibraryFolder): Promise<void> {
|
||||
await ask(FOLDERS, 'readwrite', (s) => s.put(folder));
|
||||
cachedFoldersList = null;
|
||||
}
|
||||
|
||||
// A rename is a label over the folder, not a new folder: everything downstream —
|
||||
// the frame ids, the thumbnail store, the recipe each frame was left at — hangs
|
||||
// off `name`, which stays the directory's own.
|
||||
@@ -468,7 +509,7 @@ export async function removeFolder(name: string): Promise<void> {
|
||||
//
|
||||
// `from` is the folder the reading was pointed at, '' for the whole roll; `met`
|
||||
// is every frame it found, spelled relative to the roll.
|
||||
async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
|
||||
export async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
|
||||
try {
|
||||
const root = normPath(folder);
|
||||
const under = (from ? `${root}/${normPath(from)}` : root).toLowerCase();
|
||||
@@ -603,6 +644,12 @@ export async function saveWalkedDirs(
|
||||
}
|
||||
}
|
||||
if (counts) {
|
||||
// Folded before the noughts below are filled in, not after: a reading that
|
||||
// spelled its keys the way it found them — a server's node, whose album
|
||||
// labels are the server's own — would have `??=` write a nought beside the
|
||||
// lower-case spelling of a key it has just counted, and the fold that
|
||||
// settles the two spellings into one would keep the nought.
|
||||
counts = fold(counts);
|
||||
// A folder the reading was in and met no frame under is a folder with
|
||||
// nothing under it: left out of the record, the row falls back on the
|
||||
// catalogue, and a frame taken off the disk keeps its number for good.
|
||||
@@ -947,6 +994,10 @@ export async function scanFolder(
|
||||
jump?: () => string | null,
|
||||
from = ''
|
||||
): Promise<ScanProgress> {
|
||||
// A server's node is not on a disk: there is nothing here to walk, and its rows
|
||||
// came from the sync in `immich.ts` rather than from a directory of files.
|
||||
const handle = folder.handle;
|
||||
if (!handle) throw new Error('no-folder-handle');
|
||||
const rawKnown = await listPhotos(folder.name);
|
||||
const known = new Map<string, LibraryPhoto>();
|
||||
for (const p of rawKnown) {
|
||||
@@ -966,7 +1017,7 @@ export async function scanFolder(
|
||||
if (p === from || p.startsWith(from)) treeWalked.delete(p);
|
||||
}
|
||||
}
|
||||
const tree: Walk = { root: folder.handle, pending: [], walked: treeWalked };
|
||||
const tree: Walk = { root: handle, pending: [], walked: treeWalked };
|
||||
// Whether this reading took up a position, read here and not after the walk:
|
||||
// `tree.walked` is this same set and the walk fills it as it goes, so a reading
|
||||
// that started from the top and one that started from a position look alike
|
||||
@@ -978,21 +1029,21 @@ export async function scanFolder(
|
||||
// picked one, or the one the reader right-clicked inside it.
|
||||
if (saved) {
|
||||
for (const rel of saved.pending) {
|
||||
const dir = await openAt(folder.handle, rel);
|
||||
const dir = await openAt(handle, rel);
|
||||
if (dir) tree.pending.push({ dir, rel });
|
||||
}
|
||||
if (from && !tree.pending.some((p) => p.rel === from)) {
|
||||
const dir = await openAt(folder.handle, from);
|
||||
const dir = await openAt(handle, from);
|
||||
if (dir) tree.pending.unshift({ dir, rel: from });
|
||||
}
|
||||
} else {
|
||||
const dir = from ? await openAt(folder.handle, from) : folder.handle;
|
||||
const dir = from ? await openAt(handle, from) : handle;
|
||||
if (dir) tree.pending.push({ dir, rel: from });
|
||||
}
|
||||
// The frames this reading has found and not read: the walk fills it, the lanes
|
||||
// empty it, and a frame taken off the front is one a reload will not read
|
||||
// again. Also where a reading that was cut off picks up.
|
||||
const entries: WalkFile[] = saved ? await framesAt(folder.handle, saved.frames) : [];
|
||||
const entries: WalkFile[] = saved ? await framesAt(handle, saved.frames) : [];
|
||||
// What the passes found: the folders this scan walked into, and the names they
|
||||
// put in the column. `names` is cleared a pass at a time; `found` keeps the
|
||||
// whole scan so a folder that has gone from the disk goes from the column too.
|
||||
@@ -1650,6 +1701,9 @@ export function jumpScan(rel: string | null): void {
|
||||
// cut off, and what a frame written over is found by.
|
||||
export function startScan(folder: LibraryFolder, from = '', mode: ScanMode = 'update'): Promise<ScanProgress> {
|
||||
if (live) return Promise.reject(new Error('a scan is already running'));
|
||||
// A server's node is not walked: its frames are read by `syncServer`, over the
|
||||
// routes, and a reading pointed at one is a reading of nothing.
|
||||
if (!folder.handle) return Promise.reject(new Error('no-folder-handle'));
|
||||
// A reading kept to one folder under the roll keeps no position of its own and
|
||||
// reads that folder however it was asked for, so only a roll's own rescan has a
|
||||
// position to throw away — and throwing away a position this reading is not
|
||||
@@ -1713,8 +1767,9 @@ export interface ScanJob {
|
||||
|
||||
interface Waiting extends ScanJob {
|
||||
// The picker's own handle, which is what a reading is started from: a name is
|
||||
// not a folder that can be walked.
|
||||
handle: FileSystemDirectoryHandle;
|
||||
// not a folder that can be walked. Absent when the request named a server's node
|
||||
// — there is nothing to walk it with, and the reading refuses itself.
|
||||
handle?: FileSystemDirectoryHandle;
|
||||
dropped: boolean;
|
||||
settle: (p: ScanProgress | null) => void;
|
||||
fail: (err: unknown) => void;
|
||||
@@ -2012,6 +2067,20 @@ export function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
async function makeOneThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
const kept = await getPhotoThumb(photo.id);
|
||||
if (kept) return kept;
|
||||
// A server's frame has no file to open: its tile is the one Immich drew, asked
|
||||
// for here and kept, so the second look comes out of the store and the sync's
|
||||
// own download is only paid once.
|
||||
if (!photo.handle && photo.remoteId) {
|
||||
try {
|
||||
const res = await fetch(remoteUrl('thumbnail', photo));
|
||||
if (!res.ok) return null;
|
||||
const made = await res.blob();
|
||||
void writeThumb(photo.id, made, false);
|
||||
return made;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!photo.handle) return null;
|
||||
try {
|
||||
const file = await readPhotoFile(photo);
|
||||
@@ -2184,9 +2253,32 @@ export async function setRotation(id: string, rot: 0 | 90 | 180 | 270): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// Where one of a frame's renditions is asked for. A frame that is a server's is
|
||||
// never fetched here by its bytes except by `readPhotoFile` below: the wall and
|
||||
// the stage take Immich's own rendition through this, which is a URL an `<img>`
|
||||
// or a blob fetch can both use, and which the proxy answers with a day of cache.
|
||||
export const IMMICH_SIZES = ['thumbnail', 'preview', 'fullsize'] as const;
|
||||
export type ImmichSize = (typeof IMMICH_SIZES)[number];
|
||||
|
||||
export function remoteUrl(size: ImmichSize | 'original', photo: { server?: string; remoteId?: string }): string {
|
||||
const route = size === 'original' ? 'original' : 'thumb';
|
||||
const params = new URLSearchParams({ server: photo.server ?? '', id: photo.remoteId ?? '' });
|
||||
if (size !== 'original') params.set('size', size);
|
||||
return `/api/immich/${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// The frame itself, at last: nothing is read from the disk until this runs, so
|
||||
// the catalogue costs thumbnails and no more.
|
||||
// the catalogue costs thumbnails and no more. A frame that is a server's is read
|
||||
// over the proxy, whole — it is the largest thing this app ever asks Immich for,
|
||||
// which is why only opening the studio asks it.
|
||||
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
|
||||
if (photo.remoteId) {
|
||||
const res = await fetch(remoteUrl('original', photo));
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
return new File([blob], photo.name, { type: blob.type || 'application/octet-stream' });
|
||||
}
|
||||
if (!photo.handle) throw new Error('no-file');
|
||||
return photo.handle.getFile();
|
||||
}
|
||||
|
||||
@@ -2244,6 +2336,14 @@ export async function listEdits(): Promise<LibraryEdit[]> {
|
||||
// is what lets the walk that follows match every frame and read none of them.
|
||||
export type CatalogueRow = Omit<LibraryPhoto, 'handle' | 'thumb'>;
|
||||
|
||||
// Put rows in the catalogue with no backup behind them — the same write
|
||||
// `restoreCatalogue` does, for a source whose rows came from somewhere other than
|
||||
// a disk this page walked. Every row goes in as it came; a row already here is
|
||||
// replaced by the newer word on it, which is what a sync is.
|
||||
export async function putPhotos(rows: CatalogueRow[]): Promise<number> {
|
||||
return restoreCatalogue(rows, []);
|
||||
}
|
||||
|
||||
// The rows a set of ids stands for, gaps left as gaps — one transaction for the
|
||||
// lot, where `getPhoto` would be one per frame.
|
||||
async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> {
|
||||
|
||||
@@ -316,7 +316,13 @@ export interface BackupResult extends BackupStatus {
|
||||
|
||||
export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise<BackupResult> {
|
||||
const root = await writable();
|
||||
const [photos, edits, folders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
|
||||
const [all, edits, allFolders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
|
||||
// The copy is the disk's: a node that is really an Immich server and the frames
|
||||
// read over the network from it are not on this machine, so they are not part of
|
||||
// what a folder on this machine can hold. The catalogue still keeps them — see
|
||||
// `immich.ts`, which reads them again on the next visit.
|
||||
const photos = all.filter((p) => !p.remoteId);
|
||||
const folders = allFolders.filter((f) => !f.source);
|
||||
const had = await already();
|
||||
const at0 = Date.now();
|
||||
const cat = {
|
||||
|
||||
@@ -336,6 +336,38 @@ export const en: Dict = {
|
||||
'lib.repairRun': 'Repairing the library ({n} folders)…',
|
||||
'lib.repairDone': 'Library repaired: {n} folders read back.',
|
||||
|
||||
// Immich (src/engine/immich.ts, src/ui/ImmichDialog.tsx, Library.tsx): one more
|
||||
// source beside the folders on disk — a server the account keeps, read through
|
||||
// the backend's own routes. The key never reaches this page.
|
||||
'lib.immich.add': 'ADD IMMICH',
|
||||
'lib.immich.title': 'ADD AN IMMICH SERVER',
|
||||
'lib.immich.albumsChoose': 'CHOOSE ALBUMS',
|
||||
'lib.immich.name': 'Name shown',
|
||||
'lib.immich.nameHint': 'Leave blank to use the hostname',
|
||||
'lib.immich.url': 'Server address',
|
||||
'lib.immich.key': 'API key',
|
||||
'lib.immich.keyShare': 'This is a share link, not an API key',
|
||||
'lib.immich.keyCheck': 'CHECK THE KEY',
|
||||
'lib.immich.checking': 'CHECKING…',
|
||||
'lib.immich.perms':
|
||||
'The API key needs all six read permissions: album.read, album.statistics, asset.read, asset.view, asset.download and asset.statistics.',
|
||||
'lib.immich.ok': 'The key works.',
|
||||
'lib.immich.albumsPick': 'Tick the albums to keep in the library. Ticking none means all of them.',
|
||||
'lib.immich.albumIds': 'Or paste album IDs',
|
||||
'lib.immich.albumIdsHint': 'One ID per line, or a whole …/albums/<id> address',
|
||||
'lib.immich.noDownload': 'This key cannot download originals — frames can be viewed and graded from their previews only.',
|
||||
'lib.immich.sync': 'READ AGAIN',
|
||||
'lib.immich.disconnect': 'DISCONNECT',
|
||||
'lib.immich.keep': 'REMOVE FROM LIBRARY',
|
||||
'lib.immich.failed': 'This server could not be read.',
|
||||
'lib.immich.auth': 'The key is wrong or too narrow.',
|
||||
'lib.immich.unreachable': 'That address could not be reached.',
|
||||
'lib.immich.serverErr': 'The server answered with something unusable.',
|
||||
'lib.immich.invalid': 'An address and a key are both needed.',
|
||||
'lib.immich.empty': 'This key sees no album.',
|
||||
'lib.immich.added': 'Read {n} frames from {folder}.',
|
||||
'lib.immich.hint': 'Right-click to read again, choose albums, disconnect or remove it from the library.',
|
||||
|
||||
'nav.admin': 'Admin',
|
||||
'nav.photos': 'My photos',
|
||||
'nav.profile': 'Profile',
|
||||
|
||||
@@ -348,6 +348,38 @@ export const vi = {
|
||||
'lib.repairRun': 'Đang sửa dữ liệu thư viện ({n} thư mục)…',
|
||||
'lib.repairDone': 'Đã sửa dữ liệu thư viện: đọc lại {n} thư mục.',
|
||||
|
||||
// Immich (src/engine/immich.ts, src/ui/ImmichDialog.tsx, Library.tsx): một
|
||||
// nguồn ảnh nữa bên cạnh các thư mục trên máy — một máy chủ mà tài khoản đã
|
||||
// lưu, đọc qua các route của backend. Khoá không bao giờ xuống trang này.
|
||||
'lib.immich.add': 'THÊM IMMICH',
|
||||
'lib.immich.title': 'THÊM MÁY CHỦ IMMICH',
|
||||
'lib.immich.albumsChoose': 'CHỌN ALBUM',
|
||||
'lib.immich.name': 'Tên hiển thị',
|
||||
'lib.immich.nameHint': 'Bỏ trống để lấy tên miền',
|
||||
'lib.immich.url': 'Địa chỉ máy chủ',
|
||||
'lib.immich.key': 'Khoá API',
|
||||
'lib.immich.keyShare': 'Đây là link chia sẻ, không phải API key',
|
||||
'lib.immich.keyCheck': 'KIỂM TRA KHOÁ',
|
||||
'lib.immich.checking': 'ĐANG KIỂM TRA…',
|
||||
'lib.immich.perms':
|
||||
'Khoá API cần đủ 6 quyền đọc: album.read, album.statistics, asset.read, asset.view, asset.download và asset.statistics.',
|
||||
'lib.immich.ok': 'Khoá dùng được.',
|
||||
'lib.immich.albumsPick': 'Tick những album đưa vào thư viện. Không tick gì nghĩa là lấy tất cả.',
|
||||
'lib.immich.albumIds': 'Hoặc dán ID album',
|
||||
'lib.immich.albumIdsHint': 'Mỗi dòng một ID, hoặc dán cả đường dẫn …/albums/<id>',
|
||||
'lib.immich.noDownload': 'Khoá này không có quyền tải ảnh gốc — chỉ xem và chỉnh được bản xem trước.',
|
||||
'lib.immich.sync': 'ĐỌC LẠI',
|
||||
'lib.immich.disconnect': 'NGẮT KẾT NỐI',
|
||||
'lib.immich.keep': 'XOÁ KHỎI THƯ VIỆN',
|
||||
'lib.immich.failed': 'Không đọc được máy chủ này.',
|
||||
'lib.immich.auth': 'Khoá sai hoặc thiếu quyền đọc.',
|
||||
'lib.immich.unreachable': 'Không kết nối được tới địa chỉ này.',
|
||||
'lib.immich.serverErr': 'Máy chủ trả lời không đọc được.',
|
||||
'lib.immich.invalid': 'Cần có địa chỉ và khoá.',
|
||||
'lib.immich.empty': 'Khoá này không thấy album nào.',
|
||||
'lib.immich.added': 'Đã đọc {n} ảnh từ {folder}.',
|
||||
'lib.immich.hint': 'Chuột phải để đọc lại, chọn album, ngắt kết nối hoặc xoá khỏi thư viện.',
|
||||
|
||||
'adm.title': 'Quản trị dải phim',
|
||||
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
|
||||
'adm.upload': 'THÊM ẢNH',
|
||||
|
||||
@@ -1042,6 +1042,29 @@ button.hint:disabled { cursor: default; }
|
||||
gap: 10px;
|
||||
}
|
||||
.modal h2 { margin: 0; font-size: 17px; }
|
||||
/* The dialog that adds an Immich server (src/ui/ImmichDialog.tsx): fields, a
|
||||
key that is checked, and the albums a key can see — a list that can run to
|
||||
hundreds, so it scrolls inside a box rather than pushing the buttons off the
|
||||
screen. The form itself is capped for the same reason. */
|
||||
form[data-key='immich-modal'] {
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.immich-albums {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.immich-albums label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
/* The one row in the export menu that is not a plain button: a number to type
|
||||
and the button that takes it. */
|
||||
.export-custom {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import {
|
||||
listImmichAlbums,
|
||||
probeImmich,
|
||||
saveImmichAlbums,
|
||||
saveImmichServer,
|
||||
type ImmichAlbum,
|
||||
type ImmichError,
|
||||
type ImmichServer,
|
||||
} from '../engine/immich';
|
||||
|
||||
// The dialog that puts a server in the column, and the one that changes what an
|
||||
// existing node keeps. The key only ever goes up: nothing here writes it anywhere
|
||||
// but the backend's own call, which probes before it stores, so a saved server is
|
||||
// one that answered.
|
||||
//
|
||||
// `server` is the node being edited — its albums, read live, so one added on
|
||||
// Immich since the last visit is a row to tick. Without it the dialog adds: the
|
||||
// address, the key, "check the key" (which stores nothing, so a typo is never a
|
||||
// row), the albums, and the id field for the albums a key is too narrow to list.
|
||||
//
|
||||
// ponytail: album ticks and the id field are one list — a tick is an id, and the
|
||||
// field is the way in for an album the API will not name. The field takes a whole
|
||||
// `…/albums/<uuid>` address as readily as the uuid, since that is where a reader
|
||||
// copies one from.
|
||||
export function ImmichDialog({
|
||||
server,
|
||||
defaultUrl,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
server?: ImmichServer | null;
|
||||
defaultUrl: string;
|
||||
onSaved: (saved: ImmichServer, isNew: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const editing = Boolean(server);
|
||||
const [name, setName] = useState(server?.name ?? '');
|
||||
const [url, setUrl] = useState(server?.url ?? defaultUrl);
|
||||
const [key, setKey] = useState('');
|
||||
const [type, setType] = useState<'api' | 'share'>(server?.type ?? 'api');
|
||||
const [albums, setAlbums] = useState<ImmichAlbum[] | null>(null);
|
||||
const [ticked, setTicked] = useState<Set<string>>(() => new Set(server?.albums ?? []));
|
||||
const [ids, setIds] = useState('');
|
||||
const [canDownload, setCanDownload] = useState<boolean | null>(server?.canDownload ?? null);
|
||||
const [checkedAt, setCheckedAt] = useState<'none' | 'ok' | 'fail'>(editing ? 'ok' : 'none');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// A share link *is* one album, so there is no picker for it: the node is the
|
||||
// album the link names, and the backend reads it through the share routes.
|
||||
const share = type === 'share';
|
||||
|
||||
const why = (error: ImmichError) =>
|
||||
t(
|
||||
error === 'auth'
|
||||
? 'lib.immich.auth'
|
||||
: error === 'unreachable'
|
||||
? 'lib.immich.unreachable'
|
||||
: error === 'invalid'
|
||||
? 'lib.immich.invalid'
|
||||
: 'lib.immich.serverErr'
|
||||
);
|
||||
|
||||
// An existing node's albums are asked for live rather than drawn off the saved
|
||||
// row: an album kept since the last visit and one deleted on the server both
|
||||
// have to read right, and the saved list is neither.
|
||||
useEffect(() => {
|
||||
if (!server) return;
|
||||
let alive = true;
|
||||
void listImmichAlbums(server.id).then((rows) => {
|
||||
if (alive && rows.length) setAlbums(rows);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [server]);
|
||||
|
||||
// What the reader means to keep: the ticked albums and every uuid the field
|
||||
// carries — a whole address, a list on one line, or both.
|
||||
const chosen = (): string[] => {
|
||||
const out = new Set(ticked);
|
||||
for (const raw of ids.split(/[\s,;]+/)) {
|
||||
const hit = raw.trim().match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||
if (hit) out.add(hit[0].toLowerCase());
|
||||
}
|
||||
return [...out];
|
||||
};
|
||||
|
||||
const check = async () => {
|
||||
setBusy(true);
|
||||
setProblem(null);
|
||||
const res = await probeImmich(url.trim(), key.trim(), type);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setCheckedAt('fail');
|
||||
setCanDownload(null);
|
||||
setProblem(why(res.error));
|
||||
return;
|
||||
}
|
||||
setCheckedAt('ok');
|
||||
setCanDownload(res.canDownload);
|
||||
setAlbums(res.albums);
|
||||
// A fresh server keeps everything its key can see until the reader says
|
||||
// otherwise: an empty tick list means "all of them", so ticking them is the
|
||||
// same decision spelled out where it can be changed.
|
||||
if (!share) setTicked(new Set(res.albums.map((a) => a.id)));
|
||||
};
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProblem(null);
|
||||
if (editing && server) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const kept = await saveImmichAlbums(server.id, chosen());
|
||||
onSaved({ ...server, albums: kept }, false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!url.trim() || !key.trim()) {
|
||||
setProblem(t('lib.immich.invalid'));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await saveImmichServer({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
key: key.trim(),
|
||||
type,
|
||||
albums: share ? [] : chosen(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setCheckedAt('fail');
|
||||
setProblem(why(res.error));
|
||||
return;
|
||||
}
|
||||
onSaved(res.server, true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<form className="modal" data-key="immich-modal" onSubmit={save}>
|
||||
<h2>{editing ? t('lib.immich.albumsChoose') : t('lib.immich.title')}</h2>
|
||||
|
||||
{editing && server ? (
|
||||
<p className="hint" data-key="immich-where">
|
||||
{server.url} · {server.keyMasked}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="hint" htmlFor="immich-name">
|
||||
{t('lib.immich.name')}
|
||||
</label>
|
||||
<input
|
||||
id="immich-name"
|
||||
className="field"
|
||||
data-key="immich-name"
|
||||
value={name}
|
||||
maxLength={60}
|
||||
placeholder={t('lib.immich.nameHint')}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<label className="hint" htmlFor="immich-url">
|
||||
{t('lib.immich.url')}
|
||||
</label>
|
||||
<input
|
||||
id="immich-url"
|
||||
className="field"
|
||||
data-key="immich-url"
|
||||
value={url}
|
||||
placeholder="https://photos.example.com"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<label className="hint" htmlFor="immich-key">
|
||||
{t('lib.immich.key')}
|
||||
</label>
|
||||
<input
|
||||
id="immich-key"
|
||||
className="field"
|
||||
data-key="immich-key"
|
||||
type="password"
|
||||
value={key}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
<label className="hint">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key="immich-share"
|
||||
checked={share}
|
||||
onChange={(e) => setType(e.target.checked ? 'share' : 'api')}
|
||||
/>
|
||||
{t('lib.immich.keyShare')}
|
||||
</label>
|
||||
<p className="hint" data-key="immich-perms">
|
||||
{t('lib.immich.perms')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="immich-check"
|
||||
disabled={busy || !url.trim() || !key.trim()}
|
||||
onClick={() => void check()}
|
||||
>
|
||||
{busy ? t('lib.immich.checking') : t('lib.immich.keyCheck')}
|
||||
</button>
|
||||
{checkedAt !== 'none' ? (
|
||||
<p className={checkedAt === 'ok' ? 'hint' : 'err'} data-key="immich-status">
|
||||
{checkedAt === 'ok' ? t('lib.immich.ok') : problem}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{albums && !share ? (
|
||||
<>
|
||||
<p className="hint" data-key="immich-albums-hint">
|
||||
{albums.length ? t('lib.immich.albumsPick') : t('lib.immich.empty')}
|
||||
</p>
|
||||
<ul className="immich-albums" data-key="immich-albums">
|
||||
{albums.map((a) => (
|
||||
<li key={a.id}>
|
||||
<label className="hint">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-key={`immich-album-${a.id}`}
|
||||
checked={ticked.has(a.id)}
|
||||
onChange={(e) =>
|
||||
setTicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (e.target.checked) next.add(a.id);
|
||||
else next.delete(a.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
{a.albumName?.trim() || a.id.slice(0, 8)} ({a.assetCount})
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!share ? (
|
||||
<>
|
||||
<label className="hint" htmlFor="immich-ids">
|
||||
{t('lib.immich.albumIds')}
|
||||
</label>
|
||||
<textarea
|
||||
id="immich-ids"
|
||||
className="field"
|
||||
data-key="immich-ids"
|
||||
rows={2}
|
||||
value={ids}
|
||||
placeholder={t('lib.immich.albumIdsHint')}
|
||||
onChange={(e) => setIds(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canDownload === false ? (
|
||||
<p className="err" data-key="immich-nodownload">
|
||||
{t('lib.immich.noDownload')}
|
||||
</p>
|
||||
) : null}
|
||||
{problem && checkedAt === 'none' ? (
|
||||
<p className="err" data-key="immich-error">
|
||||
{problem}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn primary" data-key="immich-save" disabled={busy}>
|
||||
{t('name.save')}
|
||||
</button>
|
||||
<button type="button" className="btn ghost" data-key="immich-cancel" onClick={onClose}>
|
||||
{t('name.cancel')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user