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
+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>
);
}