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
+345
View File
@@ -0,0 +1,345 @@
// Immich, as a second photo source beside the folders on disk — the page's half.
//
// Nothing here talks to Immich: the key never reaches the browser, and the
// address never leaves the backend, so every call below is one of the routes in
// `backend/src/immich.ts` on this page's own origin. What this file does is turn
// a server's answer into the two shapes the LIBRARY already knows — a row in the
// `photos` store and a name in the column — so the tree, the wall and the strip
// draw a server's frames with the code they already had for a folder's.
import {
breathe,
missingThumbs,
normPath,
prunePhotos,
putFolder,
putPhotoThumb,
putPhotos,
remoteUrl,
saveWalkedDirs,
type LibraryDir,
type LibraryFolder,
type LibraryPhoto,
} from './library';
// Immich answers 200 items at most for one page of a search (the backend caps it
// there), so this is the size the sync loops at: one round trip per two hundred
// frames rather than one per frame.
const PAGE = 200;
// Tiles are drawn after the rows have landed rather than before: the column and
// the count are what the reader sees first, and a tile is the wall's own business.
const TILE_LANES = 3;
export interface ImmichAlbum {
id: string;
albumName: string;
assetCount: number;
coverId: string | null;
shared: boolean;
}
export interface ImmichAsset {
id: string;
name: string;
takenAt: string | null;
width: number | null;
height: number | null;
size: number | null;
}
export interface ImmichServer {
id: string;
name: string;
url: string;
type: 'api' | 'share';
version: string | null;
canDownload: boolean | null;
albums: string[];
keyMasked: string;
}
// The three refusals the dialog can say something about, plus the one it can only
// say as a shape: the key is wrong or too narrow, the box cannot be reached, it
// answered with something unusable, or what was typed is not an address and a key.
export type ImmichError = 'auth' | 'unreachable' | 'server' | 'invalid';
export type ImmichProbe =
| { ok: true; version: string | null; albums: ImmichAlbum[]; canDownload: boolean | null }
| { ok: false; error: ImmichError };
export type ImmichSaved = { ok: true; server: ImmichServer } | { ok: false; error: ImmichError };
// One place reads a JSON answer, so a route that answered with a page of HTML —
// a proxy in the way, a box that is not Immich — is a refusal rather than a crash
// somewhere further in.
async function json<T>(res: Response): Promise<T | null> {
try {
return (await res.json()) as T;
} catch {
return null;
}
}
async function get<T>(path: string): Promise<T | null> {
try {
return await json<T>(await fetch(path));
} catch {
return null;
}
}
// The saved servers, plus the address this deployment suggests for the first one.
// One call per session: the tree has to draw on the first paint, and a server
// list that is asked for on every render is a round trip per render.
let configOnce: Promise<{ defaultUrl: string; servers: ImmichServer[] }> | null = null;
export function immichConfig(fresh = false): Promise<{ defaultUrl: string; servers: ImmichServer[] }> {
if (fresh || !configOnce) {
configOnce = get<{ defaultUrl?: string; servers?: ImmichServer[] }>('/api/immich/config').then((body) =>
body ? { defaultUrl: body.defaultUrl ?? '', servers: body.servers ?? [] } : { defaultUrl: '', servers: [] }
);
}
return configOnce;
}
export function forgetImmichConfig(): void {
configOnce = null;
}
// "Check this key" — nothing is stored, so a typo never becomes a row.
export async function probeImmich(url: string, key: string, type: 'api' | 'share'): Promise<ImmichProbe> {
try {
const res = await fetch('/api/immich/probe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url, key, type }),
});
const body = await json<ImmichProbe>(res);
return body ?? { ok: false, error: 'server' };
} catch {
return { ok: false, error: 'unreachable' };
}
}
// Add a server, or change one. The backend probes before it stores, so a server
// that comes back saved is one that answered — `albums` is the ticked set, sent
// on the same call that saves the key so a fresh server is never briefly "all".
export async function saveImmichServer(input: {
id?: string;
name?: string;
url?: string;
key?: string;
type?: 'api' | 'share';
albums?: string[];
}): Promise<ImmichSaved> {
try {
const res = await fetch('/api/immich/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
const body = await json<ImmichSaved>(res);
if (body?.ok) forgetImmichConfig();
return body ?? { ok: false, error: 'server' };
} catch {
return { ok: false, error: 'unreachable' };
}
}
export async function forgetImmichServer(id: string): Promise<boolean> {
try {
const res = await fetch(`/api/immich/config?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) forgetImmichConfig();
return res.ok;
} catch {
return false;
}
}
// The albums this key can see, asked for live: an album added on Immich has to be
// there the next time the dialog is opened, so nothing here is cached.
export async function listImmichAlbums(server: string): Promise<ImmichAlbum[]> {
const body = await get<{ ok?: boolean; albums?: ImmichAlbum[] }>(
`/api/immich/albums?id=${encodeURIComponent(server)}`
);
return body?.albums ?? [];
}
export async function saveImmichAlbums(server: string, selected: string[]): Promise<string[]> {
try {
const res = await fetch('/api/immich/albums', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: server, selected }),
});
const body = await json<{ albums?: string[] }>(res);
return body?.albums ?? [];
} catch {
return [];
}
}
// One page of a server's frames. `album` is an Immich id or null for everything
// the key can see — the album field's empty state.
async function listAssets(
server: string,
album: string | null,
page: number
): Promise<{ items: ImmichAsset[]; hasMore: boolean }> {
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
if (album) query.set('album', album);
const body = await get<{ items?: ImmichAsset[]; hasMore?: boolean }>(`/api/immich/assets?${query.toString()}`);
return { items: body?.items ?? [], hasMore: body?.hasMore === true };
}
// What the tree paints, and the name an album's frames are filed under. An album
// with no name — a key too narrow to read one, an album the reader never titled —
// is known by the first of its id, which is what the plan asks for.
function albumLabels(albums: ImmichAlbum[]): Map<string, string> {
const taken = new Set<string>();
const out = new Map<string, string>();
for (const album of albums) {
let label = album.albumName.trim() || album.id.slice(0, 8);
// Two albums may carry one name — Immich allows it — and a column is drawn
// off the path, so a second of that name would be a row the first one already
// is. The uuid is what tells them apart, and eight of it is enough to read.
if (taken.has(label)) label = `${label} ~${album.id.slice(0, 8)}`;
taken.add(label);
out.set(album.id, label);
}
return out;
}
// The `~` suffix a frame takes when an album holds two files of one name: the id
// is the name, so one of them has to move rather than one of them disappear.
function frameName(name: string, id: string, taken: Set<string>): string {
if (!taken.has(name)) return name;
return `${name} ~${id.slice(0, 8)}`;
}
export interface ImmichSyncResult {
total: number;
albums: number;
// The album rows this read named, spelled the way the column spells a path. The
// screen puts these in the place of the node's old ones: an album the reader
// unticked is a row that has to go, and a record that is only ever added to
// would keep drawing it.
dirs: LibraryDir[];
}
// Read a server's albums into the catalogue: the rows and the column first, so
// the count and the tree are right the moment this returns, and the tiles after,
// a lane at a time, so a reader who opens the node while it is still fetching
// sees frames rather than a blank wall.
//
// One read covers the whole node. There is no per-album read: the album list is
// one call, a page of frames is one call, and a column that says one album is
// fresh while the others are a week old is a column nobody can read a number off.
export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResult> {
const server = folder.server ?? '';
const albums = await listImmichAlbums(server);
const labels = albumLabels(albums);
const chosen = folder.albums ?? [];
// An empty tick list is a decision and not a missing value: it means every album
// this key can see (see the plan, the album-ID field).
const wanted = albums.filter((a) => chosen.length === 0 || chosen.includes(a.id));
const rows: LibraryPhoto[] = [];
const dirs: LibraryDir[] = [];
const counts: Record<string, number> = {};
const root = normPath(folder.name);
for (const album of wanted) {
const label = labels.get(album.id) ?? album.id.slice(0, 8);
// The names met in this one album. Two files of one name are two files, and
// the id is the name, so the second of them moves rather than disappears.
const taken = new Set<string>();
let page = 1;
let count = 0;
for (;;) {
const { items, hasMore } = await listAssets(server, album.id, page);
for (const item of items) {
if (!item.id || !item.name) continue;
const name = frameName(item.name, item.id, taken);
taken.add(item.name);
rows.push({
id: `${root}/${label}/${name}`,
folder: folder.name,
dir: label,
name,
server,
remoteId: item.id,
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
});
count++;
}
if (!hasMore || !items.length) break;
page++;
}
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
counts[`${root}/${label}`] = count;
}
counts[root] = rows.length;
await saveWalkedDirs(root, dirs, counts, true);
// A frame the server no longer lists is not a frame to keep drawing: the row,
// its tile and its marks go, exactly as a frame taken off the disk does. This
// read saw the whole node, so it is the one allowed to say it.
const met = new Set(rows.map((r) => `${normPath(r.dir)}/${r.name}`.toLowerCase()));
await prunePhotos(root, '', met);
await putPhotos(rows);
// And the tiles, for the frames that have none. A frame whose tile is already
// kept is not asked for again, so the second sync of a roll costs the rows.
const missing = new Set(await missingThumbs(rows.map((r) => r.id)));
const queue = rows.filter((r) => missing.has(r.id));
await Promise.all(
Array.from({ length: Math.min(TILE_LANES, queue.length) }, async () => {
while (queue.length) {
const row = queue.shift()!;
try {
const res = await fetch(remoteUrl('thumbnail', row));
if (res.ok) await putPhotoThumb(row.id, await res.blob());
} catch {
// One frame's tile is one frame's: the wall draws a blank cell and the
// reader may ask again, rather than a sync that stops halfway.
}
await breathe();
}
})
);
return { total: rows.length, albums: wanted.length, dirs };
}
// The name a server's node takes: the first one is `Immich`, and a second is
// numbered, because the name is the store's key and two keys cannot be one.
export function immichFolderName(taken: Iterable<string>): string {
const used = new Set(taken);
let n = 1;
while (used.has(n === 1 ? 'Immich' : `Immich ${n}`)) n++;
return n === 1 ? 'Immich' : `Immich ${n}`;
}
// What the column paints for that node: the source and the name its owner gave it.
export function immichFolderLabel(serverName: string): string {
return `Immich · ${serverName}`;
}
// Put a saved server in the column as a folder. The row is the page's own — a
// name, the server it reads, and the albums it keeps — and `taken` is what it must
// not collide with, because the name is the store's key and two keys cannot be
// one. The name is `Immich`, `Immich 2`, …; the label is what the tree paints.
export async function addImmichFolder(server: ImmichServer, taken: Iterable<string>): Promise<LibraryFolder> {
const folder: LibraryFolder = {
name: immichFolderName(taken),
label: immichFolderLabel(server.name),
source: 'immich',
server: server.id,
albums: server.albums,
kind: server.type === 'share' ? 'share' : 'album',
};
await putFolder(folder);
return folder;
}
+115 -15
View File
@@ -79,7 +79,24 @@ export interface LibraryFolder {
name: string;
// What the tree paints instead, when the visitor has renamed the folder.
label?: string;
handle: FileSystemDirectoryHandle;
// A folder of the disk has one. A folder that is really a server — the LIBRARY's
// Immich node — has none, and every frame under it is read over the network by
// the routes in `immich.ts` instead of off a handle.
handle?: FileSystemDirectoryHandle;
// Set on a node whose frames are an Immich server's, absent on the disk's. A
// node is one of these or the other and never both: nothing on this screen
// offers a disk action — a rescan, a permission — on a server, and nothing
// offers a server action on a folder.
source?: 'immich';
// The saved server this node reads, by the id the backend minted. Never an
// address: an address only ever goes up once, when one is added.
server?: string;
// The albums this node keeps, by Immich's own id. Absent or empty is the album
// field's empty state — every album the key can see.
albums?: string[];
// 'album' for a node built from album names, 'share' for one built from a share
// link: a share link *is* one album, so it has no picker and no "Người".
kind?: 'album' | 'share';
}
export interface LibraryPhoto {
@@ -91,7 +108,12 @@ export interface LibraryPhoto {
// This is what the tree column reads.
dir: string;
name: string;
handle: FileSystemFileHandle;
handle?: FileSystemFileHandle;
// The server and Immich's own uuid for this frame, on a frame whose bytes are a
// server's rather than the disk's. Both absent together: the uuid is what every
// route above reads the frame by, and the server says which key fetches it.
remoteId?: string;
server?: string;
// The tile the catalogue kept of this frame, where one has been made: it is
// made when the frame is first drawn and not when it is found, so a frame just
// read has none. Null is the same as absent — a tile that could not be made.
@@ -277,6 +299,7 @@ export async function findFolderRelationship(
| { type: 'root' }
> {
for (const existing of existingFolders) {
if (!existing.handle) continue;
try {
const relParts = await existing.handle.resolve(newHandle);
if (relParts && relParts.length > 0) {
@@ -286,6 +309,7 @@ export async function findFolderRelationship(
}
for (const existing of existingFolders) {
if (!existing.handle) continue;
try {
const relParts = await newHandle.resolve(existing.handle);
if (relParts && relParts.length > 0) {
@@ -314,6 +338,14 @@ function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore
let cachedFoldersList: LibraryFolder[] | null = null;
// A server is not a folder on a disk, and a reader who has just added one is
// looking for it at the top of the column rather than among ten drives in the
// order of their names. The name is what tells two of either apart, so it is the
// only other thing this orders by.
export function bySourceThenName(a: LibraryFolder, b: LibraryFolder): number {
return (a.source ? 0 : 1) - (b.source ? 0 : 1) || a.name.localeCompare(b.name);
}
export function cachedFolders(): LibraryFolder[] {
return cachedFoldersList ?? [];
}
@@ -321,7 +353,7 @@ export function cachedFolders(): LibraryFolder[] {
export async function listFolders(): Promise<LibraryFolder[]> {
try {
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
const sorted = rows.sort((a, b) => a.name.localeCompare(b.name));
const sorted = rows.sort(bySourceThenName);
cachedFoldersList = sorted;
return sorted;
} catch {
@@ -341,8 +373,8 @@ export async function reconnectPhotosForFolders(
folderTargets.push({ name: newFolderHandle.name, handle: newFolderHandle });
const knownFolderNames = new Set([
...existingFolders.map((f) => f.name),
...allPhotos.map((p) => p.folder),
...existingFolders.filter((f) => !f.source).map((f) => f.name),
...allPhotos.filter((p) => !p.remoteId).map((p) => p.folder),
]);
for (const name of knownFolderNames) {
@@ -369,7 +401,7 @@ export async function reconnectPhotosForFolders(
const photosToUpdate: LibraryPhoto[] = [];
for (const target of folderTargets) {
const matchingPhotos = allPhotos.filter((p) => p.folder === target.name && !p.handle);
const matchingPhotos = allPhotos.filter((p) => p.folder === target.name && !p.handle && !p.remoteId);
if (!matchingPhotos.length) continue;
const dirCache = new Map<string, FileSystemDirectoryHandle>();
@@ -422,6 +454,15 @@ export async function pickFolder(): Promise<LibraryFolder | null> {
return folder;
}
// A row that is not a folder on the disk — the LIBRARY's Immich node — is put in
// the same store under the same key, so the column draws it beside the drives and
// finds it again on the next visit. It is the page's own row: nothing here needs a
// handle to hold it, and every frame under it is read by `immich.ts`.
export async function putFolder(folder: LibraryFolder): Promise<void> {
await ask(FOLDERS, 'readwrite', (s) => s.put(folder));
cachedFoldersList = null;
}
// A rename is a label over the folder, not a new folder: everything downstream —
// the frame ids, the thumbnail store, the recipe each frame was left at — hangs
// off `name`, which stays the directory's own.
@@ -468,7 +509,7 @@ export async function removeFolder(name: string): Promise<void> {
//
// `from` is the folder the reading was pointed at, '' for the whole roll; `met`
// is every frame it found, spelled relative to the roll.
async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
export async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
try {
const root = normPath(folder);
const under = (from ? `${root}/${normPath(from)}` : root).toLowerCase();
@@ -603,6 +644,12 @@ export async function saveWalkedDirs(
}
}
if (counts) {
// Folded before the noughts below are filled in, not after: a reading that
// spelled its keys the way it found them — a server's node, whose album
// labels are the server's own — would have `??=` write a nought beside the
// lower-case spelling of a key it has just counted, and the fold that
// settles the two spellings into one would keep the nought.
counts = fold(counts);
// A folder the reading was in and met no frame under is a folder with
// nothing under it: left out of the record, the row falls back on the
// catalogue, and a frame taken off the disk keeps its number for good.
@@ -947,6 +994,10 @@ export async function scanFolder(
jump?: () => string | null,
from = ''
): Promise<ScanProgress> {
// A server's node is not on a disk: there is nothing here to walk, and its rows
// came from the sync in `immich.ts` rather than from a directory of files.
const handle = folder.handle;
if (!handle) throw new Error('no-folder-handle');
const rawKnown = await listPhotos(folder.name);
const known = new Map<string, LibraryPhoto>();
for (const p of rawKnown) {
@@ -966,7 +1017,7 @@ export async function scanFolder(
if (p === from || p.startsWith(from)) treeWalked.delete(p);
}
}
const tree: Walk = { root: folder.handle, pending: [], walked: treeWalked };
const tree: Walk = { root: handle, pending: [], walked: treeWalked };
// Whether this reading took up a position, read here and not after the walk:
// `tree.walked` is this same set and the walk fills it as it goes, so a reading
// that started from the top and one that started from a position look alike
@@ -978,21 +1029,21 @@ export async function scanFolder(
// picked one, or the one the reader right-clicked inside it.
if (saved) {
for (const rel of saved.pending) {
const dir = await openAt(folder.handle, rel);
const dir = await openAt(handle, rel);
if (dir) tree.pending.push({ dir, rel });
}
if (from && !tree.pending.some((p) => p.rel === from)) {
const dir = await openAt(folder.handle, from);
const dir = await openAt(handle, from);
if (dir) tree.pending.unshift({ dir, rel: from });
}
} else {
const dir = from ? await openAt(folder.handle, from) : folder.handle;
const dir = from ? await openAt(handle, from) : handle;
if (dir) tree.pending.push({ dir, rel: from });
}
// The frames this reading has found and not read: the walk fills it, the lanes
// empty it, and a frame taken off the front is one a reload will not read
// again. Also where a reading that was cut off picks up.
const entries: WalkFile[] = saved ? await framesAt(folder.handle, saved.frames) : [];
const entries: WalkFile[] = saved ? await framesAt(handle, saved.frames) : [];
// What the passes found: the folders this scan walked into, and the names they
// put in the column. `names` is cleared a pass at a time; `found` keeps the
// whole scan so a folder that has gone from the disk goes from the column too.
@@ -1650,6 +1701,9 @@ export function jumpScan(rel: string | null): void {
// cut off, and what a frame written over is found by.
export function startScan(folder: LibraryFolder, from = '', mode: ScanMode = 'update'): Promise<ScanProgress> {
if (live) return Promise.reject(new Error('a scan is already running'));
// A server's node is not walked: its frames are read by `syncServer`, over the
// routes, and a reading pointed at one is a reading of nothing.
if (!folder.handle) return Promise.reject(new Error('no-folder-handle'));
// A reading kept to one folder under the roll keeps no position of its own and
// reads that folder however it was asked for, so only a roll's own rescan has a
// position to throw away — and throwing away a position this reading is not
@@ -1713,8 +1767,9 @@ export interface ScanJob {
interface Waiting extends ScanJob {
// The picker's own handle, which is what a reading is started from: a name is
// not a folder that can be walked.
handle: FileSystemDirectoryHandle;
// not a folder that can be walked. Absent when the request named a server's node
// — there is nothing to walk it with, and the reading refuses itself.
handle?: FileSystemDirectoryHandle;
dropped: boolean;
settle: (p: ScanProgress | null) => void;
fail: (err: unknown) => void;
@@ -2012,6 +2067,20 @@ export function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
async function makeOneThumb(photo: LibraryPhoto): Promise<Blob | null> {
const kept = await getPhotoThumb(photo.id);
if (kept) return kept;
// A server's frame has no file to open: its tile is the one Immich drew, asked
// for here and kept, so the second look comes out of the store and the sync's
// own download is only paid once.
if (!photo.handle && photo.remoteId) {
try {
const res = await fetch(remoteUrl('thumbnail', photo));
if (!res.ok) return null;
const made = await res.blob();
void writeThumb(photo.id, made, false);
return made;
} catch {
return null;
}
}
if (!photo.handle) return null;
try {
const file = await readPhotoFile(photo);
@@ -2184,9 +2253,32 @@ export async function setRotation(id: string, rot: 0 | 90 | 180 | 270): Promise<
}
}
// Where one of a frame's renditions is asked for. A frame that is a server's is
// never fetched here by its bytes except by `readPhotoFile` below: the wall and
// the stage take Immich's own rendition through this, which is a URL an `<img>`
// or a blob fetch can both use, and which the proxy answers with a day of cache.
export const IMMICH_SIZES = ['thumbnail', 'preview', 'fullsize'] as const;
export type ImmichSize = (typeof IMMICH_SIZES)[number];
export function remoteUrl(size: ImmichSize | 'original', photo: { server?: string; remoteId?: string }): string {
const route = size === 'original' ? 'original' : 'thumb';
const params = new URLSearchParams({ server: photo.server ?? '', id: photo.remoteId ?? '' });
if (size !== 'original') params.set('size', size);
return `/api/immich/${route}?${params.toString()}`;
}
// The frame itself, at last: nothing is read from the disk until this runs, so
// the catalogue costs thumbnails and no more.
// the catalogue costs thumbnails and no more. A frame that is a server's is read
// over the proxy, whole — it is the largest thing this app ever asks Immich for,
// which is why only opening the studio asks it.
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
if (photo.remoteId) {
const res = await fetch(remoteUrl('original', photo));
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
return new File([blob], photo.name, { type: blob.type || 'application/octet-stream' });
}
if (!photo.handle) throw new Error('no-file');
return photo.handle.getFile();
}
@@ -2244,6 +2336,14 @@ export async function listEdits(): Promise<LibraryEdit[]> {
// is what lets the walk that follows match every frame and read none of them.
export type CatalogueRow = Omit<LibraryPhoto, 'handle' | 'thumb'>;
// Put rows in the catalogue with no backup behind them — the same write
// `restoreCatalogue` does, for a source whose rows came from somewhere other than
// a disk this page walked. Every row goes in as it came; a row already here is
// replaced by the newer word on it, which is what a sync is.
export async function putPhotos(rows: CatalogueRow[]): Promise<number> {
return restoreCatalogue(rows, []);
}
// The rows a set of ids stands for, gaps left as gaps — one transaction for the
// lot, where `getPhoto` would be one per frame.
async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> {
+7 -1
View File
@@ -316,7 +316,13 @@ export interface BackupResult extends BackupStatus {
export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise<BackupResult> {
const root = await writable();
const [photos, edits, folders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
const [all, edits, allFolders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
// The copy is the disk's: a node that is really an Immich server and the frames
// read over the network from it are not on this machine, so they are not part of
// what a folder on this machine can hold. The catalogue still keeps them — see
// `immich.ts`, which reads them again on the next visit.
const photos = all.filter((p) => !p.remoteId);
const folders = allFolders.filter((f) => !f.source);
const had = await already();
const at0 = Date.now();
const cat = {