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:
2026-10-10 16:16:37 +07:00
parent d2e2ebe108
commit e27998d820
9 changed files with 1529 additions and 60 deletions
+414
View File
@@ -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
View File
@@ -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>
);
}
+345
View File
@@ -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;
}
+115 -15
View File
@@ -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)[]> {
+7 -1
View File
@@ -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 = {
+32
View File
@@ -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',
+32
View File
@@ -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',
+23
View File
@@ -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 {
+290
View File
@@ -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>
);
}