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 eacb3b7341
commit 1d6a869094
9 changed files with 1529 additions and 60 deletions
+115 -15
View File
@@ -79,7 +79,24 @@ export interface LibraryFolder {
name: string;
// What the tree paints instead, when the visitor has renamed the folder.
label?: string;
handle: FileSystemDirectoryHandle;
// A folder of the disk has one. A folder that is really a server — the LIBRARY's
// Immich node — has none, and every frame under it is read over the network by
// the routes in `immich.ts` instead of off a handle.
handle?: FileSystemDirectoryHandle;
// Set on a node whose frames are an Immich server's, absent on the disk's. A
// node is one of these or the other and never both: nothing on this screen
// offers a disk action — a rescan, a permission — on a server, and nothing
// offers a server action on a folder.
source?: 'immich';
// The saved server this node reads, by the id the backend minted. Never an
// address: an address only ever goes up once, when one is added.
server?: string;
// The albums this node keeps, by Immich's own id. Absent or empty is the album
// field's empty state — every album the key can see.
albums?: string[];
// 'album' for a node built from album names, 'share' for one built from a share
// link: a share link *is* one album, so it has no picker and no "Người".
kind?: 'album' | 'share';
}
export interface LibraryPhoto {
@@ -91,7 +108,12 @@ export interface LibraryPhoto {
// This is what the tree column reads.
dir: string;
name: string;
handle: FileSystemFileHandle;
handle?: FileSystemFileHandle;
// The server and Immich's own uuid for this frame, on a frame whose bytes are a
// server's rather than the disk's. Both absent together: the uuid is what every
// route above reads the frame by, and the server says which key fetches it.
remoteId?: string;
server?: string;
// The tile the catalogue kept of this frame, where one has been made: it is
// made when the frame is first drawn and not when it is found, so a frame just
// read has none. Null is the same as absent — a tile that could not be made.
@@ -277,6 +299,7 @@ export async function findFolderRelationship(
| { type: 'root' }
> {
for (const existing of existingFolders) {
if (!existing.handle) continue;
try {
const relParts = await existing.handle.resolve(newHandle);
if (relParts && relParts.length > 0) {
@@ -286,6 +309,7 @@ export async function findFolderRelationship(
}
for (const existing of existingFolders) {
if (!existing.handle) continue;
try {
const relParts = await newHandle.resolve(existing.handle);
if (relParts && relParts.length > 0) {
@@ -314,6 +338,14 @@ function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore
let cachedFoldersList: LibraryFolder[] | null = null;
// A server is not a folder on a disk, and a reader who has just added one is
// looking for it at the top of the column rather than among ten drives in the
// order of their names. The name is what tells two of either apart, so it is the
// only other thing this orders by.
export function bySourceThenName(a: LibraryFolder, b: LibraryFolder): number {
return (a.source ? 0 : 1) - (b.source ? 0 : 1) || a.name.localeCompare(b.name);
}
export function cachedFolders(): LibraryFolder[] {
return cachedFoldersList ?? [];
}
@@ -321,7 +353,7 @@ export function cachedFolders(): LibraryFolder[] {
export async function listFolders(): Promise<LibraryFolder[]> {
try {
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
const sorted = rows.sort((a, b) => a.name.localeCompare(b.name));
const sorted = rows.sort(bySourceThenName);
cachedFoldersList = sorted;
return sorted;
} catch {
@@ -341,8 +373,8 @@ export async function reconnectPhotosForFolders(
folderTargets.push({ name: newFolderHandle.name, handle: newFolderHandle });
const knownFolderNames = new Set([
...existingFolders.map((f) => f.name),
...allPhotos.map((p) => p.folder),
...existingFolders.filter((f) => !f.source).map((f) => f.name),
...allPhotos.filter((p) => !p.remoteId).map((p) => p.folder),
]);
for (const name of knownFolderNames) {
@@ -369,7 +401,7 @@ export async function reconnectPhotosForFolders(
const photosToUpdate: LibraryPhoto[] = [];
for (const target of folderTargets) {
const matchingPhotos = allPhotos.filter((p) => p.folder === target.name && !p.handle);
const matchingPhotos = allPhotos.filter((p) => p.folder === target.name && !p.handle && !p.remoteId);
if (!matchingPhotos.length) continue;
const dirCache = new Map<string, FileSystemDirectoryHandle>();
@@ -422,6 +454,15 @@ export async function pickFolder(): Promise<LibraryFolder | null> {
return folder;
}
// A row that is not a folder on the disk — the LIBRARY's Immich node — is put in
// the same store under the same key, so the column draws it beside the drives and
// finds it again on the next visit. It is the page's own row: nothing here needs a
// handle to hold it, and every frame under it is read by `immich.ts`.
export async function putFolder(folder: LibraryFolder): Promise<void> {
await ask(FOLDERS, 'readwrite', (s) => s.put(folder));
cachedFoldersList = null;
}
// A rename is a label over the folder, not a new folder: everything downstream —
// the frame ids, the thumbnail store, the recipe each frame was left at — hangs
// off `name`, which stays the directory's own.
@@ -468,7 +509,7 @@ export async function removeFolder(name: string): Promise<void> {
//
// `from` is the folder the reading was pointed at, '' for the whole roll; `met`
// is every frame it found, spelled relative to the roll.
async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
export async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
try {
const root = normPath(folder);
const under = (from ? `${root}/${normPath(from)}` : root).toLowerCase();
@@ -603,6 +644,12 @@ export async function saveWalkedDirs(
}
}
if (counts) {
// Folded before the noughts below are filled in, not after: a reading that
// spelled its keys the way it found them — a server's node, whose album
// labels are the server's own — would have `??=` write a nought beside the
// lower-case spelling of a key it has just counted, and the fold that
// settles the two spellings into one would keep the nought.
counts = fold(counts);
// A folder the reading was in and met no frame under is a folder with
// nothing under it: left out of the record, the row falls back on the
// catalogue, and a frame taken off the disk keeps its number for good.
@@ -947,6 +994,10 @@ export async function scanFolder(
jump?: () => string | null,
from = ''
): Promise<ScanProgress> {
// A server's node is not on a disk: there is nothing here to walk, and its rows
// came from the sync in `immich.ts` rather than from a directory of files.
const handle = folder.handle;
if (!handle) throw new Error('no-folder-handle');
const rawKnown = await listPhotos(folder.name);
const known = new Map<string, LibraryPhoto>();
for (const p of rawKnown) {
@@ -966,7 +1017,7 @@ export async function scanFolder(
if (p === from || p.startsWith(from)) treeWalked.delete(p);
}
}
const tree: Walk = { root: folder.handle, pending: [], walked: treeWalked };
const tree: Walk = { root: handle, pending: [], walked: treeWalked };
// Whether this reading took up a position, read here and not after the walk:
// `tree.walked` is this same set and the walk fills it as it goes, so a reading
// that started from the top and one that started from a position look alike
@@ -978,21 +1029,21 @@ export async function scanFolder(
// picked one, or the one the reader right-clicked inside it.
if (saved) {
for (const rel of saved.pending) {
const dir = await openAt(folder.handle, rel);
const dir = await openAt(handle, rel);
if (dir) tree.pending.push({ dir, rel });
}
if (from && !tree.pending.some((p) => p.rel === from)) {
const dir = await openAt(folder.handle, from);
const dir = await openAt(handle, from);
if (dir) tree.pending.unshift({ dir, rel: from });
}
} else {
const dir = from ? await openAt(folder.handle, from) : folder.handle;
const dir = from ? await openAt(handle, from) : handle;
if (dir) tree.pending.push({ dir, rel: from });
}
// The frames this reading has found and not read: the walk fills it, the lanes
// empty it, and a frame taken off the front is one a reload will not read
// again. Also where a reading that was cut off picks up.
const entries: WalkFile[] = saved ? await framesAt(folder.handle, saved.frames) : [];
const entries: WalkFile[] = saved ? await framesAt(handle, saved.frames) : [];
// What the passes found: the folders this scan walked into, and the names they
// put in the column. `names` is cleared a pass at a time; `found` keeps the
// whole scan so a folder that has gone from the disk goes from the column too.
@@ -1650,6 +1701,9 @@ export function jumpScan(rel: string | null): void {
// cut off, and what a frame written over is found by.
export function startScan(folder: LibraryFolder, from = '', mode: ScanMode = 'update'): Promise<ScanProgress> {
if (live) return Promise.reject(new Error('a scan is already running'));
// A server's node is not walked: its frames are read by `syncServer`, over the
// routes, and a reading pointed at one is a reading of nothing.
if (!folder.handle) return Promise.reject(new Error('no-folder-handle'));
// A reading kept to one folder under the roll keeps no position of its own and
// reads that folder however it was asked for, so only a roll's own rescan has a
// position to throw away — and throwing away a position this reading is not
@@ -1713,8 +1767,9 @@ export interface ScanJob {
interface Waiting extends ScanJob {
// The picker's own handle, which is what a reading is started from: a name is
// not a folder that can be walked.
handle: FileSystemDirectoryHandle;
// not a folder that can be walked. Absent when the request named a server's node
// — there is nothing to walk it with, and the reading refuses itself.
handle?: FileSystemDirectoryHandle;
dropped: boolean;
settle: (p: ScanProgress | null) => void;
fail: (err: unknown) => void;
@@ -2012,6 +2067,20 @@ export function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
async function makeOneThumb(photo: LibraryPhoto): Promise<Blob | null> {
const kept = await getPhotoThumb(photo.id);
if (kept) return kept;
// A server's frame has no file to open: its tile is the one Immich drew, asked
// for here and kept, so the second look comes out of the store and the sync's
// own download is only paid once.
if (!photo.handle && photo.remoteId) {
try {
const res = await fetch(remoteUrl('thumbnail', photo));
if (!res.ok) return null;
const made = await res.blob();
void writeThumb(photo.id, made, false);
return made;
} catch {
return null;
}
}
if (!photo.handle) return null;
try {
const file = await readPhotoFile(photo);
@@ -2184,9 +2253,32 @@ export async function setRotation(id: string, rot: 0 | 90 | 180 | 270): Promise<
}
}
// Where one of a frame's renditions is asked for. A frame that is a server's is
// never fetched here by its bytes except by `readPhotoFile` below: the wall and
// the stage take Immich's own rendition through this, which is a URL an `<img>`
// or a blob fetch can both use, and which the proxy answers with a day of cache.
export const IMMICH_SIZES = ['thumbnail', 'preview', 'fullsize'] as const;
export type ImmichSize = (typeof IMMICH_SIZES)[number];
export function remoteUrl(size: ImmichSize | 'original', photo: { server?: string; remoteId?: string }): string {
const route = size === 'original' ? 'original' : 'thumb';
const params = new URLSearchParams({ server: photo.server ?? '', id: photo.remoteId ?? '' });
if (size !== 'original') params.set('size', size);
return `/api/immich/${route}?${params.toString()}`;
}
// The frame itself, at last: nothing is read from the disk until this runs, so
// the catalogue costs thumbnails and no more.
// the catalogue costs thumbnails and no more. A frame that is a server's is read
// over the proxy, whole — it is the largest thing this app ever asks Immich for,
// which is why only opening the studio asks it.
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
if (photo.remoteId) {
const res = await fetch(remoteUrl('original', photo));
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
return new File([blob], photo.name, { type: blob.type || 'application/octet-stream' });
}
if (!photo.handle) throw new Error('no-file');
return photo.handle.getFile();
}
@@ -2244,6 +2336,14 @@ export async function listEdits(): Promise<LibraryEdit[]> {
// is what lets the walk that follows match every frame and read none of them.
export type CatalogueRow = Omit<LibraryPhoto, 'handle' | 'thumb'>;
// Put rows in the catalogue with no backup behind them — the same write
// `restoreCatalogue` does, for a source whose rows came from somewhere other than
// a disk this page walked. Every row goes in as it came; a row already here is
// replaced by the newer word on it, which is what a sync is.
export async function putPhotos(rows: CatalogueRow[]): Promise<number> {
return restoreCatalogue(rows, []);
}
// The rows a set of ids stands for, gaps left as gaps — one transaction for the
// lot, where `getPhoto` would be one per frame.
async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> {