improve: Immich cloud filter and optimize
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user