From e27998d820757e4884eb9eda46fcd75bc1ce3ac6 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 10 Oct 2026 16:16:37 +0700 Subject: [PATCH] feat(immich): draw a server as a folder in the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/scripts/immich-check.mjs | 414 ++++++++++++++++++++ docker/frontend/src/Library.tsx | 315 ++++++++++++--- docker/frontend/src/engine/immich.ts | 345 ++++++++++++++++ docker/frontend/src/engine/library.ts | 130 +++++- docker/frontend/src/engine/libraryBackup.ts | 8 +- docker/frontend/src/i18n/en.ts | 32 ++ docker/frontend/src/i18n/vi.ts | 32 ++ docker/frontend/src/styles/app.css | 23 ++ docker/frontend/src/ui/ImmichDialog.tsx | 290 ++++++++++++++ 9 files changed, 1529 insertions(+), 60 deletions(-) create mode 100644 docker/frontend/scripts/immich-check.mjs create mode 100644 docker/frontend/src/engine/immich.ts create mode 100644 docker/frontend/src/ui/ImmichDialog.tsx diff --git a/docker/frontend/scripts/immich-check.mjs b/docker/frontend/scripts/immich-check.mjs new file mode 100644 index 0000000..3ed0a83 --- /dev/null +++ b/docker/frontend/scripts/immich-check.mjs @@ -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= 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); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 2f137e3..080828a 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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(() => cachedFolders()); const [ready, setReady] = useState>(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([]); + // 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(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(null); const stageUrlRef = useRef(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 ?

{note}

: null} - {canBrowseFolders() ? ( + {canBrowseFolders() || servers.length ? (
{/* 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. */}
- + ) : 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. */} + {live || scanQueue().length ? ( - + 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() ? ( + <> + + + + ) : 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 }) {
) : ( -

{t('lib.unsupported')}

+ <> +

{t('lib.unsupported')}

+ {/* 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. */} + + )} {/* The context menu: a folder gets what belongs to a folder rather than to a @@ -2644,7 +2796,63 @@ function Catalogue({ admin }: { admin: boolean }) { ) : 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. */} + + {menu.folder.kind !== 'share' ? ( + + ) : null} + + + + ) : (ready.has(menu.folder.name) || ready.has((menu.folder.name || '').replace(/\\/g, '/'))) ? ( <> {!menu.root ? (
) : 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 ? ( + void serverSaved(saved, isNew)} + onClose={() => setAddImmich(false)} + /> + ) : null} + {editAlbums ? ( + s.id === editAlbums.server) ?? null} + defaultUrl="" + onSaved={(saved, isNew) => void serverSaved(saved, isNew)} + onClose={() => setEditAlbums(null)} + /> + ) : null} ); } diff --git a/docker/frontend/src/engine/immich.ts b/docker/frontend/src/engine/immich.ts new file mode 100644 index 0000000..29aa56e --- /dev/null +++ b/docker/frontend/src/engine/immich.ts @@ -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(res: Response): Promise { + try { + return (await res.json()) as T; + } catch { + return null; + } +} + +async function get(path: string): Promise { + try { + return await json(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 { + 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(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 { + try { + const res = await fetch('/api/immich/config', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + const body = await json(res); + if (body?.ok) forgetImmichConfig(); + return body ?? { ok: false, error: 'server' }; + } catch { + return { ok: false, error: 'unreachable' }; + } +} + +export async function forgetImmichServer(id: string): Promise { + 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 { + 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 { + 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 { + const taken = new Set(); + const out = new Map(); + 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 { + 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 { + 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 = {}; + 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(); + 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 { + 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): Promise { + 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; +} diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index c3ece58..b576031 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -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(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 { try { const rows = await ask(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(); @@ -422,6 +454,15 @@ export async function pickFolder(): Promise { 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 { + 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 { // // `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): Promise { +export async function prunePhotos(folder: string, from: string, met: Set): Promise { 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 { + // 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(); 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 { 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 { async function makeOneThumb(photo: LibraryPhoto): Promise { 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 `` +// 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 { + 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 { // is what lets the walk that follows match every frame and read none of them. export type CatalogueRow = Omit; +// 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 { + 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)[]> { diff --git a/docker/frontend/src/engine/libraryBackup.ts b/docker/frontend/src/engine/libraryBackup.ts index 61aa49a..1ba0d78 100644 --- a/docker/frontend/src/engine/libraryBackup.ts +++ b/docker/frontend/src/engine/libraryBackup.ts @@ -316,7 +316,13 @@ export interface BackupResult extends BackupStatus { export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise { 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 = { diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index cae50b2..3971896 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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/ 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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index aa42569..0a55101 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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/', + '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', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index c985612..8ceebfc 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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 { diff --git a/docker/frontend/src/ui/ImmichDialog.tsx b/docker/frontend/src/ui/ImmichDialog.tsx new file mode 100644 index 0000000..b41afcb --- /dev/null +++ b/docker/frontend/src/ui/ImmichDialog.tsx @@ -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/` 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(null); + const [ticked, setTicked] = useState>(() => new Set(server?.albums ?? [])); + const [ids, setIds] = useState(''); + const [canDownload, setCanDownload] = useState(server?.canDownload ?? null); + const [checkedAt, setCheckedAt] = useState<'none' | 'ok' | 'fail'>(editing ? 'ok' : 'none'); + const [problem, setProblem] = useState(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 ( +
e.target === e.currentTarget && onClose()}> +
+

{editing ? t('lib.immich.albumsChoose') : t('lib.immich.title')}

+ + {editing && server ? ( +

+ {server.url} · {server.keyMasked} +

+ ) : ( + <> + + setName(e.target.value)} + /> + + setUrl(e.target.value)} + /> + + setKey(e.target.value)} + /> + +

+ {t('lib.immich.perms')} +

+ + {checkedAt !== 'none' ? ( +

+ {checkedAt === 'ok' ? t('lib.immich.ok') : problem} +

+ ) : null} + + )} + + {albums && !share ? ( + <> +

+ {albums.length ? t('lib.immich.albumsPick') : t('lib.immich.empty')} +

+
    + {albums.map((a) => ( +
  • + +
  • + ))} +
+ + ) : null} + + {!share ? ( + <> + +