improve: Immich cloud filter and optimize

This commit is contained in:
2026-10-11 20:18:04 +07:00
parent f37a093a23
commit 0665796e37
15 changed files with 4699 additions and 87 deletions
+88 -56
View File
@@ -238,84 +238,103 @@ export interface ImmichSyncResult {
// 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 ?? '';
// A search node is one query and no albums: the backend reads it as a name or a
// tag, and what it answers with is filed under the node itself — a label of `''`
// is the node's own wall — so the album list is not even asked for.
const search = folder.kind === 'search' ? (folder.query ?? '').trim() : '';
const albums = search ? [] : 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: { id: string | null; label: string }[] = search
? [{ id: null, label: '' }]
: albums
.filter((a) => chosen.length === 0 || chosen.includes(a.id))
.map((a) => ({ id: a.id, label: labels.get(a.id) ?? a.id.slice(0, 8) }));
const root = normPath(folder.name);
const rows: LibraryPhoto[] = [];
const dirs: LibraryDir[] = [];
const counts: Record<string, number> = {};
const root = normPath(folder.name);
for (const album of wanted) {
const label = album.label;
// 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, search);
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({
// A search node's frames hang off the node itself, so their path is one
// segment shorter than an album's.
id: [root, label, name].filter(Boolean).join('/'),
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++;
// 1. ALWAYS read ALL photos of the Immich API (filed under root of folder: dir = '')
// This guarantees that all photos (timeline, camera roll, uploads) are fully loaded into the Library.
const rootTaken = new Set<string>();
let page = 1;
let rootCount = 0;
for (;;) {
const { items, hasMore } = await listAssets(server, null, page, search);
for (const item of items) {
if (!item.id || !item.name) continue;
const name = frameName(item.name, item.id, rootTaken);
rootTaken.add(item.name);
rootTaken.add(name);
rows.push({
id: [root, name].join('/'),
folder: folder.name,
dir: '',
name,
server,
remoteId: item.id,
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
});
rootCount++;
}
// A search node has no row under it: what it answers with is the node's own
// frames, and the row for them is the node.
if (label) {
if (!hasMore || !items.length) break;
page++;
}
counts[root] = rootCount;
// 2. ALBUMS of Immich ONLY display in the ALBUM section if the user chooses specific albums (chosen.length > 0).
// When chosen is empty, dirs remains empty ([]) so no album subfolders clutter the ALBUM tree.
// When chosen has album IDs, only those specific chosen albums are fetched and populated into dirs.
if (!search && chosen.length > 0) {
const albums = await listImmichAlbums(server);
const labels = albumLabels(albums);
const chosenAlbums = albums.filter((a) => chosen.includes(a.id));
for (const album of chosenAlbums) {
const label = labels.get(album.id) ?? album.id.slice(0, 8);
const albumTaken = new Set<string>();
let albPage = 1;
let albCount = 0;
for (;;) {
const { items, hasMore } = await listAssets(server, album.id, albPage, search);
for (const item of items) {
if (!item.id || !item.name) continue;
const name = frameName(item.name, item.id, albumTaken);
albumTaken.add(item.name);
albumTaken.add(name);
rows.push({
id: [root, label, name].join('/'),
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(),
});
albCount++;
}
if (!hasMore || !items.length) break;
albPage++;
}
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
counts[`${root}/${label}`] = count;
counts[`${root}/${label}`] = albCount;
}
}
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.
// Spelled the way `prunePhotos` reads a row back: no leading slash for a frame
// that hangs off the node itself.
const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : 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.
// Pre-cache thumbnails for initial display without stalling large libraries
const missing = new Set(await missingThumbs(rows.map((r) => r.id)));
const queue = rows.filter((r) => missing.has(r.id));
const initialBatch = queue.slice(0, 80);
const backgroundBatch = queue.slice(80);
await Promise.all(
Array.from({ length: Math.min(TILE_LANES, queue.length) }, async () => {
while (queue.length) {
const row = queue.shift()!;
Array.from({ length: Math.min(TILE_LANES, initialBatch.length) }, async () => {
while (initialBatch.length) {
const row = initialBatch.shift()!;
try {
const res = await fetch(remoteUrl('thumbnail', row));
if (res.ok) await putPhotoThumb(row.id, await res.blob());
@@ -328,7 +347,20 @@ export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResul
})
);
return { total: rows.length, albums: wanted.length, dirs };
if (backgroundBatch.length) {
void (async () => {
while (backgroundBatch.length) {
const row = backgroundBatch.shift()!;
try {
const res = await fetch(remoteUrl('thumbnail', row));
if (res.ok) await putPhotoThumb(row.id, await res.blob());
} catch {}
await breathe();
}
})();
}
return { total: rootCount, albums: dirs.length, dirs };
}
// The name a server's node takes: the first one is `Immich`, and a second is
+51 -3
View File
@@ -417,7 +417,18 @@ export function bySourceThenName(a: LibraryFolder, b: LibraryFolder): number {
}
export function cachedFolders(): LibraryFolder[] {
return cachedFoldersList ?? [];
if (cachedFoldersList) return cachedFoldersList;
try {
const raw = sessionStorage.getItem('rc_cached_folders');
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
cachedFoldersList = parsed;
return cachedFoldersList;
}
}
} catch {}
return [];
}
export async function listFolders(): Promise<LibraryFolder[]> {
@@ -425,6 +436,15 @@ export async function listFolders(): Promise<LibraryFolder[]> {
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
const sorted = rows.sort(bySourceThenName);
cachedFoldersList = sorted;
try {
const slim = sorted.map((f) => ({
name: f.name,
label: f.label,
source: f.source,
server: f.server,
}));
sessionStorage.setItem('rc_cached_folders', JSON.stringify(slim));
} catch {}
return sorted;
} catch {
return cachedFoldersList ?? [];
@@ -2027,7 +2047,18 @@ function pump(): void {
let cached: LibraryPhoto[] | null = null;
export function cachedPhotos(): LibraryPhoto[] {
return cached ?? [];
if (cached) return cached;
try {
const raw = sessionStorage.getItem('rc_cached_photos');
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
cached = parsed;
return cached;
}
}
} catch {}
return [];
}
export function clearLibraryCache(): void {
@@ -2418,7 +2449,24 @@ export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null
if (chunk.length < READ_CHUNK) break;
}
rows.sort((a, b) => b.taken - a.taken);
if (!folder) cached = rows;
if (!folder) {
cached = rows;
try {
const slim = rows.slice(0, 300).map((p) => ({
id: p.id,
folder: p.folder,
name: p.name,
taken: p.taken,
addedAt: p.addedAt,
dir: p.dir,
star: p.star,
server: p.server,
remoteId: p.remoteId,
rot: p.rot,
}));
sessionStorage.setItem('rc_cached_photos', JSON.stringify(slim));
} catch {}
}
return rows;
} catch {
return null;