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:
+271
-44
@@ -9,6 +9,7 @@ import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDe
|
||||
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
|
||||
import {
|
||||
breathe,
|
||||
bySourceThenName,
|
||||
cachedFolders,
|
||||
cachedPhotos,
|
||||
canBrowseFolders,
|
||||
@@ -28,9 +29,11 @@ import {
|
||||
onLibraryBlocked,
|
||||
onThumbWritten,
|
||||
pickFolder,
|
||||
putFolder,
|
||||
readPhotoFile,
|
||||
readPhotos,
|
||||
reconnectPhotosForFolders,
|
||||
remoteUrl,
|
||||
removeFolder,
|
||||
renameFolder,
|
||||
scanAsked,
|
||||
@@ -56,6 +59,15 @@ import {
|
||||
restoreNow,
|
||||
type BackupStatus,
|
||||
} from './engine/libraryBackup';
|
||||
import {
|
||||
addImmichFolder,
|
||||
forgetImmichServer,
|
||||
immichConfig,
|
||||
saveImmichAlbums,
|
||||
syncImmich,
|
||||
type ImmichServer,
|
||||
} from './engine/immich';
|
||||
import { ImmichDialog } from './ui/ImmichDialog';
|
||||
|
||||
// The catalogue screen, laid out like the admin's picture manager: the tree of
|
||||
// folders and subfolders down the left, the frame that is up in the middle, and
|
||||
@@ -319,6 +331,17 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [folders, setFolders] = useState<LibraryFolder[]>(() => cachedFolders());
|
||||
const [ready, setReady] = useState<Set<string>>(new Set());
|
||||
// The Immich servers this account has saved, and the two dialogs that write
|
||||
// them: one adding a server, one changing which albums an existing node keeps.
|
||||
// A server is not a folder on the disk — see `engine/immich.ts` — so it is the
|
||||
// column's second source and the page holds no key of its own.
|
||||
const [servers, setServers] = useState<ImmichServer[]>([]);
|
||||
// The address this deployment suggests for a first server — `IMMICH_URL`, which
|
||||
// the backend answers with. Offered as a filled-in field, not as a saved row:
|
||||
// it is a suggestion until a key is typed against it.
|
||||
const [immichUrl, setImmichUrl] = useState('');
|
||||
const [addImmich, setAddImmich] = useState(false);
|
||||
const [editAlbums, setEditAlbums] = useState<LibraryFolder | null>(null);
|
||||
// The strip starts on the catalogue the last screen read: coming back from the
|
||||
// studio is a screen opening, not a page loading, and the frames the reader was
|
||||
// looking at are already in hand. The read below replaces them a moment later.
|
||||
@@ -759,18 +782,27 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
setFolders(rows);
|
||||
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
|
||||
|
||||
// 3. Query directory permissions in parallel
|
||||
// 3. Query directory permissions in parallel. A folder that is really an
|
||||
// Immich server has no handle to hand back — its key lives on the backend —
|
||||
// so it is ready by definition, and the column draws it beside the drives.
|
||||
const permResults = await Promise.all(
|
||||
rows.map(async (folder) => ({
|
||||
name: folder.name,
|
||||
ok: await ensurePermission(folder.handle),
|
||||
ok: folder.source === 'immich' ? true : folder.handle ? await ensurePermission(folder.handle) : false,
|
||||
}))
|
||||
);
|
||||
if (!alive) return;
|
||||
const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name));
|
||||
setReady(ok);
|
||||
|
||||
// 4. Await photos reload if still running
|
||||
// 4. The saved Immich servers, which is what the column needs to know
|
||||
// whether the second source has anything in it at all.
|
||||
const config = await immichConfig();
|
||||
if (!alive) return;
|
||||
setServers(config.servers);
|
||||
setImmichUrl(config.defaultUrl);
|
||||
|
||||
// 5. Await photos reload if still running
|
||||
await reloadPromise;
|
||||
if (!alive) return;
|
||||
setLoaded(true);
|
||||
@@ -829,6 +861,26 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
};
|
||||
}, [menu, photoMenu]);
|
||||
|
||||
// Read a server's node into the catalogue. This is to a server what a reading is
|
||||
// to a roll: the rows and the column first, the tiles after, and it is
|
||||
// `engine/immich.ts` that does the work. The node's album rows are put in the
|
||||
// place of its old ones — an album the reader unticked is a row that has to go,
|
||||
// and `reload` only ever adds.
|
||||
const syncServer = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
setNote(null);
|
||||
try {
|
||||
const res = await syncImmich(folder);
|
||||
setDirs((prev) => [...prev.filter((d) => d.folder !== folder.name), ...res.dirs]);
|
||||
setNote(t('lib.immich.added', { n: res.total, folder: folder.label ?? folder.name }));
|
||||
await reload();
|
||||
} catch {
|
||||
setNote(t('lib.immich.failed'));
|
||||
}
|
||||
},
|
||||
[reload, t]
|
||||
);
|
||||
|
||||
// A reading asked for here is a request, not a reading: one walk runs at a time,
|
||||
// and a folder asked for while another is being read waits its turn — which is
|
||||
// what makes a right click on four folders mean four readings rather than three
|
||||
@@ -836,6 +888,10 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// for, and a request called off before it ran says nothing at all.
|
||||
const scan = useCallback(
|
||||
async (folder: LibraryFolder, quiet = false, from = '', mode: ScanMode = 'update') => {
|
||||
// A server's node is not walked: its frames are read by `syncServer`, over
|
||||
// the backend's own routes. Nothing here may hand a handle-less folder to a
|
||||
// walk that would take it for a folder of the disk.
|
||||
if (folder.source === 'immich') return;
|
||||
// A quiet reading is one nobody asked to hear about — the repair's own,
|
||||
// whose note says the whole of what it is doing — so it leaves the note
|
||||
// where it found it. A loud one clears it: its word is the one that lands.
|
||||
@@ -865,11 +921,15 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// it. Frames that have not moved are skipped on their size and their time, so
|
||||
// the cost is the disk's own listing and not a develop a frame.
|
||||
const repair = useCallback(async () => {
|
||||
if (!folders.length) return;
|
||||
if (!window.confirm(t('lib.repairConfirm', { n: folders.length }))) return;
|
||||
setNote(t('lib.repairRun', { n: folders.length }));
|
||||
// The repair is of the disk: a repair reads every roll back to the disk, and a
|
||||
// server has no disk here to be read back to. Its own word is `ĐỌC LẠI` on
|
||||
// its row.
|
||||
const rolls = folders.filter((f) => !f.source);
|
||||
if (!rolls.length) return;
|
||||
if (!window.confirm(t('lib.repairConfirm', { n: rolls.length }))) return;
|
||||
setNote(t('lib.repairRun', { n: rolls.length }));
|
||||
let done = 0;
|
||||
for (const folder of folders) {
|
||||
for (const folder of rolls) {
|
||||
// One roll's failure is one roll's: a handle the browser will not hand
|
||||
// back, a drive that is not there — the rest of the repair still runs.
|
||||
try {
|
||||
@@ -917,6 +977,9 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
// again every time the tab is raised is the walk this screen just gave up.
|
||||
if (busy || scanSession() || resume) return;
|
||||
for (const folder of folders) {
|
||||
// A server has no reading to be cut off: what it was left at is one
|
||||
// round trip away, and there is no walk here for this chip to offer.
|
||||
if (folder.source) continue;
|
||||
if (!ready.has(folder.name)) continue;
|
||||
if (await unfinished(folder.name)) {
|
||||
setResume(folder.name);
|
||||
@@ -937,9 +1000,10 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
setNote(null);
|
||||
try {
|
||||
const folder = await pickFolder();
|
||||
if (!folder) return;
|
||||
if (!folder?.handle) return;
|
||||
const handle = folder.handle;
|
||||
|
||||
const relInfo = await findFolderRelationship(folder.handle, folders);
|
||||
const relInfo = await findFolderRelationship(handle, folders);
|
||||
|
||||
if (relInfo.type === 'subfolder') {
|
||||
const { parent, relPath } = relInfo;
|
||||
@@ -966,16 +1030,14 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
await scan(parent, false, relPath + '/');
|
||||
} else if (relInfo.type === 'parent') {
|
||||
const { child } = relInfo;
|
||||
const nextFolders = [...folders.filter((f) => f.name !== child.name), folder].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const nextFolders = [...folders.filter((f) => f.name !== child.name), folder].sort(bySourceThenName);
|
||||
setFolders(nextFolders);
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
setNode(folder.name);
|
||||
await scan(folder);
|
||||
} else {
|
||||
setFolders((prev) =>
|
||||
[...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name))
|
||||
[...prev.filter((f) => f.name !== folder.name), folder].sort(bySourceThenName)
|
||||
);
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
setNode(folder.name);
|
||||
@@ -989,9 +1051,13 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
|
||||
const reconnect = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
if (await ensurePermission(folder.handle)) {
|
||||
// Only a folder of the disk has a permission to be handed back: a server's
|
||||
// key lives on the backend, and this row never offers this action.
|
||||
const handle = folder.handle;
|
||||
if (!handle) return;
|
||||
if (await ensurePermission(handle)) {
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
await reconnectPhotosForFolders(folder.handle);
|
||||
await reconnectPhotosForFolders(handle);
|
||||
await scan(folder);
|
||||
} else {
|
||||
setNote(t('lib.failed'));
|
||||
@@ -1027,6 +1093,51 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
[folders, reload]
|
||||
);
|
||||
|
||||
// A server that has just been saved, or one whose albums have been reticked. A
|
||||
// new server becomes a folder on the column the moment it is saved: nothing
|
||||
// walks it, so there is nothing to wait for. An existing node keeps its name and
|
||||
// its frames, which the sync below reads again for the albums it now keeps.
|
||||
const serverSaved = useCallback(
|
||||
async (saved: ImmichServer, isNew: boolean) => {
|
||||
setAddImmich(false);
|
||||
const edited = editAlbums;
|
||||
setEditAlbums(null);
|
||||
// The saved list is read back rather than patched: the backend is what
|
||||
// holds the key, and what it says is what the dialog will show next time.
|
||||
const config = await immichConfig(true);
|
||||
setServers(config.servers);
|
||||
setImmichUrl(config.defaultUrl);
|
||||
if (!isNew && edited) {
|
||||
const next: LibraryFolder = { ...edited, albums: saved.albums };
|
||||
await putFolder(next);
|
||||
setFolders((prev) => prev.map((f) => (f.name === next.name ? next : f)));
|
||||
await syncServer(next);
|
||||
return;
|
||||
}
|
||||
const current = await listFolders();
|
||||
const folder = await addImmichFolder(saved, current.map((f) => f.name));
|
||||
setFolders([...current, folder].sort(bySourceThenName));
|
||||
setReady((prev) => new Set(prev).add(folder.name));
|
||||
setNode(folder.name);
|
||||
await syncServer(folder);
|
||||
},
|
||||
[editAlbums, syncServer]
|
||||
);
|
||||
|
||||
// Ngắt kết nối: the key goes and the node goes with it — the address and the
|
||||
// key are the account's, and a node with neither to read by is a row that could
|
||||
// only fail. "Xoá khỏi thư viện" (`drop`) is the other half: the row goes and
|
||||
// the server is left saved, so adding it back is the same address and no key at
|
||||
// all.
|
||||
const disconnect = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
if (folder.server) await forgetImmichServer(folder.server);
|
||||
await drop(folder);
|
||||
setServers((await immichConfig(true)).servers);
|
||||
},
|
||||
[drop]
|
||||
);
|
||||
|
||||
// The frame goes to the studio through the address bar: the studio owns the
|
||||
// load (develop, EXIF, session) and this page owns no part of it. While a scan
|
||||
// is running the address bar is the one thing that changes — `go` keeps the
|
||||
@@ -1829,11 +1940,12 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
const [stageFor, setStageFor] = useState<string | null>(null);
|
||||
const stageUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Only an object URL this page made is this page's to drop: a server's preview
|
||||
// is an address the browser owns, and revoking it would be revoking nothing.
|
||||
const revokeStageUrl = () => {
|
||||
if (stageUrlRef.current) {
|
||||
URL.revokeObjectURL(stageUrlRef.current);
|
||||
stageUrlRef.current = null;
|
||||
}
|
||||
const url = stageUrlRef.current;
|
||||
stageUrlRef.current = null;
|
||||
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Load high-resolution photo/preview for the raised stage frame directly from local file handle
|
||||
@@ -1843,6 +1955,17 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
setStageFor(null);
|
||||
if (!frame) return;
|
||||
|
||||
// A server's frame is not read off a disk: the stage draws the preview Immich
|
||||
// already keeps, a screen-sized copy, rather than pulling the original through
|
||||
// the proxy for a picture on a screen. A frame with no right to download has no
|
||||
// original to fetch at all, and this is the road it is viewed by.
|
||||
if (frame.remoteId) {
|
||||
stageUrlRef.current = remoteUrl('preview', frame);
|
||||
setStageUrl(stageUrlRef.current);
|
||||
setStageFor(frame.id);
|
||||
return;
|
||||
}
|
||||
|
||||
let live = true;
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -1953,7 +2076,7 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
|
||||
{note ? <p className="hint adm-note">{note}</p> : null}
|
||||
|
||||
{canBrowseFolders() ? (
|
||||
{canBrowseFolders() || servers.length ? (
|
||||
<div className="adm-frame" data-key="lib-frame">
|
||||
<div className="adm-pane">
|
||||
{/* One toolbar for the whole screen: add, the scan in flight, and the
|
||||
@@ -1961,8 +2084,22 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
same row and ellipsises — the catalogue is the content, this bar
|
||||
is furniture. */}
|
||||
<div className="chip-row lib-bar">
|
||||
<button type="button" className="btn primary" data-key="lib-add" onClick={() => void add()}>
|
||||
{t('lib.add')}
|
||||
{canBrowseFolders() ? (
|
||||
<button type="button" className="btn primary" data-key="lib-add" onClick={() => void add()}>
|
||||
{t('lib.add')}
|
||||
</button>
|
||||
) : null}
|
||||
{/* The second source: a server the account keeps, read through the
|
||||
backend's own routes. It sits beside ADD FOLDER because that is
|
||||
what it is to the reader — one more place to look — and it is
|
||||
offered whether or not this browser can open a folder at all. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-add-immich"
|
||||
onClick={() => setAddImmich(true)}
|
||||
>
|
||||
{t('lib.immich.add')}
|
||||
</button>
|
||||
{live || scanQueue().length ? (
|
||||
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
|
||||
@@ -1972,25 +2109,31 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
{/* The catalogue's own copy: one button that picks the folder the
|
||||
first time and writes into it every time after, and one that
|
||||
reads it back. Both are furniture next to the reading itself,
|
||||
so they sit behind the hint rather than in front of it. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-backup"
|
||||
disabled={backBusy}
|
||||
onClick={() => void backup()}
|
||||
>
|
||||
{t('lib.backup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-restore"
|
||||
disabled={backBusy}
|
||||
onClick={() => void restore()}
|
||||
>
|
||||
{t('lib.restore')}
|
||||
</button>
|
||||
so they sit behind the hint rather than in front of it — and
|
||||
both are the disk's, so neither is offered where no folder can
|
||||
be picked. */}
|
||||
{canBrowseFolders() ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-backup"
|
||||
disabled={backBusy}
|
||||
onClick={() => void backup()}
|
||||
>
|
||||
{t('lib.backup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="lib-restore"
|
||||
disabled={backBusy}
|
||||
onClick={() => void restore()}
|
||||
>
|
||||
{t('lib.restore')}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{/* The repair, for the account the API calls an admin: the catalogue
|
||||
this screen draws is the browser's own, and a catalogue that has
|
||||
drifted — counts under folders the disk emptied, rows for frames
|
||||
@@ -2169,7 +2312,7 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
aria-current={node === n.key}
|
||||
aria-busy={scanning}
|
||||
aria-expanded={under ? expanded.has(n.key) : undefined}
|
||||
title={t('lib.nodeHint')}
|
||||
title={folder?.source === 'immich' ? t('lib.immich.hint') : t('lib.nodeHint')}
|
||||
style={{ paddingLeft: 9 + n.depth * 12 }}
|
||||
// A row with children opens on the folder and folds it in
|
||||
// the same click: the frames below stay in the strip, only
|
||||
@@ -2614,7 +2757,16 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="adm-note hint">{t('lib.unsupported')}</p>
|
||||
<>
|
||||
<p className="adm-note hint">{t('lib.unsupported')}</p>
|
||||
{/* The second source is not the disk's and is not refused by a browser
|
||||
that will not hand a page a folder: a server is one key, and every
|
||||
browser can hold one. So it stands here too, outside the gate the
|
||||
folder's own controls live behind. */}
|
||||
<button type="button" className="btn" data-key="lib-add-immich" onClick={() => setAddImmich(true)}>
|
||||
{t('lib.immich.add')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* The context menu: a folder gets what belongs to a folder rather than to a
|
||||
@@ -2644,7 +2796,63 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
</button>
|
||||
) : null}
|
||||
{menu.folder ? (
|
||||
(ready.has(menu.folder.name) || ready.has((menu.folder.name || '').replace(/\\/g, '/'))) ? (
|
||||
menu.folder.source === 'immich' ? (
|
||||
<>
|
||||
{/* A server's node carries the server's own four things: read it
|
||||
again, change what it keeps, drop the key, or leave the row — and
|
||||
never a rescan, a rename or a tile job, which are the disk's. */}
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-immich-sync-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void syncServer(menu.folder!);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">↻</span>
|
||||
{t('lib.immich.sync')}
|
||||
</button>
|
||||
{menu.folder.kind !== 'share' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-immich-albums-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setEditAlbums(menu.folder!);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">▤</span>
|
||||
{t('lib.immich.albumsChoose')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-immich-off-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void disconnect(menu.folder!);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">⚿</span>
|
||||
{t('lib.immich.disconnect')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-drop-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void drop(menu.folder!);
|
||||
}}
|
||||
>
|
||||
<span className="lib-menu-icon" aria-hidden="true">✕</span>
|
||||
{t('lib.immich.keep')}
|
||||
</button>
|
||||
</>
|
||||
) : (ready.has(menu.folder.name) || ready.has((menu.folder.name || '').replace(/\\/g, '/'))) ? (
|
||||
<>
|
||||
{!menu.root ? (
|
||||
<button
|
||||
@@ -2909,6 +3117,25 @@ function Catalogue({ admin }: { admin: boolean }) {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The two dialogs of the second source: one adding a server, one changing
|
||||
what an existing node keeps. They are the only place a key is typed, and
|
||||
it goes straight up to the backend, which probes it before it stores. */}
|
||||
{addImmich ? (
|
||||
<ImmichDialog
|
||||
defaultUrl={immichUrl}
|
||||
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
|
||||
onClose={() => setAddImmich(false)}
|
||||
/>
|
||||
) : null}
|
||||
{editAlbums ? (
|
||||
<ImmichDialog
|
||||
server={servers.find((s) => s.id === editAlbums.server) ?? null}
|
||||
defaultUrl=""
|
||||
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
|
||||
onClose={() => setEditAlbums(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user