feat(library): read a cloud folder by its albums, and a frame by any of its paths

A folder on an Immich server is named by an album's id and a folder on this
machine by a path, and both are drawn in one column, so a preview asked for
by one spelling of an address has to be found by the others. A frame's id
now answers to the spellings of the same path — drive-letter case, either
separator, a leading slash — and the thumbnail cache holds enough of them
that a roll is not read twice.

The cloud catalogue is read by what the folder says it is. Kind 'share', or
a folder with no album ticked, is every album the key can see: one reading,
kept to its own cursor, and the frames no album names found by the
whole-library read behind it. A folder with albums ticked is those albums,
read album by album, and it is complete when the last of them is. The album
rows are written before the node's own number, so the column never says a
number and then grows rows under it.

The inference session runs without a proxy: the worker script a proxy loads
is fetched by the session's own origin, and a cross-origin page is served
by the page's.
This commit is contained in:
2026-10-15 15:32:52 +07:00
parent 9d714bcffa
commit cfdebab157
3 changed files with 319 additions and 135 deletions
+169 -63
View File
@@ -17,6 +17,7 @@ import {
clearLibraryCache,
ensurePermission,
findFolderRelationship,
getCachedThumb,
getPhoto,
getPhotoThumbs,
HEAD_BYTES,
@@ -98,6 +99,7 @@ interface Node {
label: string;
depth: number;
count: number;
folderName: string;
}
// What this screen remembers between visits: the folder that was open, the frame
@@ -582,7 +584,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const reload = useCallback(async () => {
const [rows, edits, walkedRows] = await Promise.all([
readPhotos(undefined, (firstBatch) => {
setPhotos((prev) => (prev.length < firstBatch.length ? firstBatch : prev));
setPhotos((prev) => (prev.length === 0 ? firstBatch : prev));
}),
listEditedIds(),
listWalkedDirs(),
@@ -928,7 +930,18 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const rows = await listFolders();
if (!alive) return;
setFolders(rows);
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
setNode((prev) => {
if (prev !== null) {
const normPrev = normPath(prev).toLowerCase();
const found = rows.find((f) => {
const fName = (f.name || '').toLowerCase();
const normF = normPath(f.name).toLowerCase();
return fName === normPrev || normF === normPrev || (normF && normPrev.startsWith(`${normF}/`));
});
if (found) return prev;
}
return rows[0] ? normPath(rows[0].name) : null;
});
// 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 —
@@ -963,7 +976,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// it, the width its column was dragged to, and whether the strip drew the whole
// branch under that folder or only the folder itself.
useEffect(() => {
if (node) keep(NODE_KEY, node);
if (node !== null) keep(NODE_KEY, node);
}, [node]);
useEffect(() => {
if (up) keep(FRAME_KEY, up);
@@ -1136,8 +1149,8 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}
return ok;
});
if (!node && nextFolders[0]) {
setNode(nextFolders[0].name);
if (node === null && nextFolders[0]) {
setNode(normPath(nextFolders[0].name));
}
}
})();
@@ -1534,14 +1547,29 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}, []);
const rootFolder = useMemo(() => {
if (!node) return folders[0] ?? null;
const normNode = normPath(node);
return (
folders.find((f) => {
const normF = normPath(f.name);
return normNode === normF || normNode.startsWith(`${normF}/`);
}) ?? folders[0] ?? null
);
if (!folders.length) return null;
if (node !== null) {
const normNode = normPath(node).toLowerCase();
const match = folders.find((f) => {
const fName = (f.name || '').toLowerCase();
const normF = normPath(f.name).toLowerCase();
return fName === normNode || normF === normNode;
});
if (match) return match;
const sub = folders.find((f) => {
const normF = normPath(f.name).toLowerCase();
return normF ? (normNode === normF || normNode.startsWith(`${normF}/`)) : false;
});
if (sub) return sub;
const byLabel = folders.find((f) => (f.label || '').toLowerCase() === normNode);
if (byLabel) return byLabel;
const emptyRoot = folders.find((f) => normPath(f.name) === '');
if (emptyRoot) return emptyRoot;
}
return folders[0] ?? null;
}, [folders, node]);
const rootName = rootFolder ? rootFolder.name : null;
@@ -1558,6 +1586,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const normRoot = normPath(rootName);
const normKey = normPath(key);
if (normKey === normRoot) return '';
if (normRoot === '') return `${normKey}/`;
if (normKey.startsWith(`${normRoot}/`)) return `${normKey.slice(normRoot.length + 1)}/`;
return null;
},
@@ -1612,7 +1641,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// time, and a frame that arrives after the tiles were asked for is a frame with
// none.
useEffect(() => {
if (!loaded || !node || !rootFolder) return;
if (!loaded || node === null || !rootFolder) return;
const rel = relOf(node);
if (rel === null) return;
// The rows come from the screen, which is holding the whole catalogue already:
@@ -1726,17 +1755,24 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
for (const folder of folders) {
const normF = normPath(folder.name);
const lowerF = normF.toLowerCase();
out.push({ key: normF, label: folder.label || folder.name, depth: 0, count: counted(normF) });
out.push({
key: normF,
label: folder.label || folder.name,
depth: 0,
count: counted(normF),
folderName: folder.name,
});
// Path order puts a folder before its own children, which is the order the
// indented rows have to come in.
for (const dir of [...(foldersUnder.get(lowerF) ?? [])].sort((a, b) => a.localeCompare(b))) {
const parts = dir.split('/');
const key = `${normF}/${dir}`;
const key = normF ? `${normF}/${dir}` : dir;
out.push({
key,
label: parts[parts.length - 1],
depth: parts.length,
count: counted(key),
folderName: folder.name,
});
}
}
@@ -1822,7 +1858,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// A remembered subfolder that no longer holds a frame falls back to the folder
// it sits in, so the screen never opens on a node that is not drawn.
useEffect(() => {
if (!loaded || !node || nodes.some((n) => n.key === node)) return;
if (!loaded || node === null || nodes.some((n) => n.key === node)) return;
setNode(nodes.find((n) => n.key === rootName || n.key === normPath(rootName ?? ''))?.key ?? nodes[0]?.key ?? null);
}, [loaded, node, nodes, rootName]);
@@ -1830,32 +1866,41 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// ones in every folder under it — the whole of the branch the row above it
// counted, a server's own folder among them.
const shown = useMemo(() => {
// The empty root is a root: `D:\` is what the picker calls a drive, and `''`
// is what it folds to. Read as "no folder picked" it hands back the whole
// catalogue for every row of that roll — the root's frames under a year, and
// a server's frames under both.
if (rootName === null) return photos;
const normRoot = normPath(rootName);
const normNode = normPath(node ?? '');
const dir = normNode === normRoot ? '' : normNode.slice(normRoot.length + 1);
if (!rootFolder || rootName === null) return [];
const isImmich = rootFolder.source === 'immich';
const normRoot = normPath(rootName).toLowerCase();
const normNode = normPath(node ?? '').toLowerCase();
const dir = normNode === normRoot
? ''
: (normRoot ? (normNode.startsWith(`${normRoot}/`) ? normNode.slice(normRoot.length + 1) : normNode) : normNode);
const below = dir === ''
? (p: LibraryPhoto) => normPath(p.dir ?? '') !== ''
: (p: LibraryPhoto) => normPath(p.dir ?? '').startsWith(`${dir}/`);
: (p: LibraryPhoto) => normPath(p.dir ?? '').toLowerCase().startsWith(`${dir}/`);
const own: LibraryPhoto[] = [];
const both: LibraryPhoto[] = [];
for (const p of photos) {
if (normPath(p.folder ?? '') !== normRoot) continue;
const pDir = normPath(p.dir ?? '');
if (isImmich) {
if (!p.server && !p.remoteId) continue;
if (rootFolder.server && p.server && p.server !== rootFolder.server) continue;
if (normPath(p.folder ?? '').toLowerCase() !== normRoot && p.folder !== rootFolder.name) continue;
} else {
if (p.server || p.remoteId) continue;
const pFolder = normPath(p.folder ?? '').toLowerCase();
const pIdLower = p.id.toLowerCase();
const matchesFolder =
pFolder === normRoot ||
p.folder === rootFolder.name ||
(normRoot !== '' && pIdLower.startsWith(`${normRoot}/`)) ||
(normRoot === '' && (pFolder === '' || pFolder === '\\'));
if (!matchesFolder) continue;
}
const pDir = normPath(p.dir ?? '').toLowerCase();
const isOwn = pDir === dir;
if (isOwn) own.push(p);
if (isOwn || below(p)) both.push(p);
}
// A row counts what sits at or under its folder, so a folder with nothing of
// its own to draw is drawn as its branch whatever the toggle says: a folder
// whose frames all lie one level down would otherwise open on an empty screen
// under a number that says otherwise.
return deep || !own.length ? both : own;
}, [deep, node, photos, rootName]);
}, [deep, node, photos, rootFolder, rootName]);
// What the thumbnail view draws, out of what the open node holds. A frame's
// hour is the hour the shutter fired in, on this machine's clock — the same
@@ -1933,11 +1978,13 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
for (const p of shown) {
if (p.taken) set.add(new Date(p.taken).getFullYear());
}
for (const yr of serverYears) {
set.add(yr);
if (rootFolder?.source === 'immich') {
for (const yr of serverYears) {
set.add(yr);
}
}
return Array.from(set).sort((a, b) => b - a);
}, [shown, serverYears]);
}, [shown, serverYears, rootFolder?.source]);
// A year the open folder does not hold is not a year to look at, and leaving
// it set would draw an empty shelf with no way back but the dropdown. Fires on
@@ -1989,7 +2036,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
);
useEffect(() => {
if (year === 'all' || !servers.length) return;
if (year === 'all' || !servers.length || rootFolder?.source !== 'immich') return;
const yr = Number(year);
if (!Number.isFinite(yr) || yr < 1900) return;
@@ -1997,7 +2044,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
if (!hasPhotosForYear) {
void fetchYearPhotos(yr);
}
}, [year, photos, servers, fetchYearPhotos]);
}, [year, photos, servers, fetchYearPhotos, rootFolder?.source]);
const handleSelectPhoto = useCallback(
(photo: LibraryPhoto, e: React.MouseEvent) => {
@@ -2196,14 +2243,26 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const el = wallRef.current;
if (!el || !listed.length) return;
const key = monthKey(at);
const k = dayStarts.findIndex((start) => monthKey(listed[start].taken || listed[start].addedAt) === key);
const yKey = yearKey(at);
let k = dayStarts.findIndex((start) => monthKey(listed[start].taken || listed[start].addedAt) === key);
if (k < 0) {
k = dayStarts.findIndex((start) => yearKey(listed[start].taken || listed[start].addedAt) === yKey);
}
if (k < 0) return;
const row = layout.rows[dayRow(layout.rows, k)];
if (!row) return;
thumbJob.current++;
el.scrollTop = Math.max(0, row.top - WALL_EYE);
},
[listed, dayStarts, layout, wallRef]
);
useEffect(() => {
if (wallRef.current) {
wallRef.current.scrollTop = 0;
}
}, [node]);
// A tile the studio graded while this screen was dark: the copy in hand is the
// negative's, and `onThumbWritten` hands over the one that replaces it. The
// counter is what makes the rows look again — the list is unchanged, so nothing
@@ -2229,7 +2288,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const scrolledNodeRef = useRef<string | null>(null);
useEffect(() => {
if (!node || !loaded) return;
if (node === null || !loaded) return;
if (scrolledNodeRef.current === node) return;
const scrollToNode = () => {
@@ -4147,7 +4206,7 @@ const wallAr = new Map<string, number>();
// One object URL per tile, made when the row does and dropped with it: a folder
const globalThumbUrls = new Map<string, { url: string; blob: Blob }>();
const GLOBAL_URL_MAX = 600;
const GLOBAL_URL_MAX = 2000;
function cacheObjectUrl(id: string, blob: Blob): string {
const existing = globalThumbUrls.get(id);
@@ -4179,40 +4238,49 @@ function dropThumbUrl(id: string): void {
globalThumbUrls.delete(id);
}
function getSyncThumbUrl(photo: LibraryPhoto): string | null {
const held = globalThumbUrls.get(photo.id);
if (held) return held.url;
const blob = photo.thumb ?? getCachedThumb(photo.id);
if (blob) {
return cacheObjectUrl(photo.id, blob);
}
return null;
}
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record<string, string> {
const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
const [urls, setUrls] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
// Synchronously resolve any thumb URL already available in memory to eliminate render flicker/flashing
const syncUrls = useMemo(() => {
const res: Record<string, string> = {};
for (const p of list) {
const cached = globalThumbUrls.get(p.id);
if (cached) initial[p.id] = cached.url;
const u = getSyncThumbUrl(p);
if (u) res[p.id] = u;
}
return initial;
});
return res;
}, [list, stamp]);
const [asyncUrls, setAsyncUrls] = useState<Record<string, string>>({});
const urls = useMemo(() => {
return { ...asyncUrls, ...syncUrls };
}, [asyncUrls, syncUrls]);
useEffect(() => {
let alive = true;
if (!list.length) {
setUrls({});
setAsyncUrls({});
return;
}
const syncUrls: Record<string, string> = {};
const idsToFetch: string[] = [];
for (const p of list) {
const cached = globalThumbUrls.get(p.id);
if (cached) {
syncUrls[p.id] = cached.url;
} else {
if (!globalThumbUrls.has(p.id) && !p.thumb && !getCachedThumb(p.id)) {
idsToFetch.push(p.id);
}
}
if (Object.keys(syncUrls).length > 0) {
setUrls((prev) => ({ ...prev, ...syncUrls }));
}
if (idsToFetch.length === 0) return;
// The tile is `makeThumb`'s business: the store first, the backup behind it,
@@ -4231,7 +4299,7 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?
if (kept) landed[photo.id] = cacheObjectUrl(photo.id, kept);
}
if (alive && Object.keys(landed).length > 0) {
setUrls((prev) => ({ ...prev, ...landed }));
setAsyncUrls((prev) => ({ ...prev, ...landed }));
}
// A server's own frame is minted here as well: its tile is the one Immich
@@ -4239,18 +4307,39 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?
// back to that year the wall draws it out of this machine instead of
// paying the round trip again — the wall is read up and down the roll, and
// every jump is otherwise the same frames asked of the server a second time.
const queue = list.filter((p) => !(p.id in landed));
const queue = list.filter((p) => !(p.id in landed) && !globalThumbUrls.has(p.id));
let batchUrls: Record<string, string> = {};
let flushTimer: any = null;
const flush = () => {
if (!alive || Object.keys(batchUrls).length === 0) return;
const cur = batchUrls;
batchUrls = {};
setAsyncUrls((prev) => ({ ...prev, ...cur }));
};
await Promise.all(
Array.from({ length: Math.min(WALL_LANES, queue.length) }, async () => {
while (alive && queue.length) {
const photo = queue.shift()!;
const blob = await makeThumb(photo);
if (!alive) return;
if (blob) setUrls((prev) => ({ ...prev, [photo.id]: cacheObjectUrl(photo.id, blob) }));
if (blob) {
batchUrls[photo.id] = cacheObjectUrl(photo.id, blob);
if (Object.keys(batchUrls).length >= 4) {
flush();
} else if (!flushTimer) {
flushTimer = setTimeout(() => {
flushTimer = null;
flush();
}, 40);
}
}
await breathe();
}
})
);
if (flushTimer) clearTimeout(flushTimer);
flush();
});
return () => {
@@ -4500,6 +4589,8 @@ function TimelineRuler({
return Math.max(0, Math.min(marks.length - 1, Math.round(frac * last)));
};
const lastPickRef = useRef<number>(-1);
return (
<div ref={rootRef} className="lib-ruler" data-key="lib-ruler" onPointerDown={(e) => e.stopPropagation()}>
<div className="lib-ruler-head">
@@ -4516,19 +4607,27 @@ function TimelineRuler({
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
const i = markAt(e.clientY);
if (i >= 0) onPick(marks[i]);
if (i >= 0 && i !== lastPickRef.current) {
lastPickRef.current = i;
onPick(marks[i]);
}
}}
onPointerMove={(e) => {
if (!dragging.current) return;
const i = markAt(e.clientY);
if (i >= 0) onPick(marks[i]);
if (i >= 0 && i !== lastPickRef.current) {
lastPickRef.current = i;
onPick(marks[i]);
}
}}
onPointerUp={(e) => {
dragging.current = false;
lastPickRef.current = -1;
e.currentTarget.releasePointerCapture(e.pointerId);
}}
onPointerCancel={() => {
dragging.current = false;
lastPickRef.current = -1;
}}
>
{/* The year's own band: the months of one year stand together under it,
@@ -4552,7 +4651,14 @@ function TimelineRuler({
data-kind={mark.kind}
data-at={mark.at}
title={mark.label}
onClick={() => onPick(mark)}
onPointerDown={(e) => {
e.stopPropagation();
onPick(mark);
}}
onClick={(e) => {
e.stopPropagation();
onPick(mark);
}}
>
{mark.kind === 'year' ? <i>{mark.label}</i> : null}
</span>
+75 -57
View File
@@ -353,6 +353,13 @@ export async function syncImmich(
const server = folder.server ?? '';
const search = folder.kind === 'search' ? (folder.query ?? '').trim() : '';
const chosen = folder.albums ?? [];
// An empty tick list is a decision and not a missing value: it means every
// album this key can see. A node that keeps all of them is therefore drawn with
// the album rows of every one of them, and its own number is what they hold
// between them — the whole-library read below is kept for the frames no album
// names, and for a search or a share link, which are the node itself.
const kind = folder.kind ?? 'album';
const keepAll = kind === 'share' || chosen.length === 0;
const root = normPath(folder.name);
// Every row this reading met, spelled the way `prunePhotos` spells one. It is
@@ -380,7 +387,7 @@ export async function syncImmich(
// the catalogue was given was then the newest three pages of it, once a visit,
// for good. The node's own record keeps the place instead — the frame it stopped
// on, and the page within that frame's own instant.
const resume = folder.cursor && !folder.cursor.done ? folder.cursor : null;
const resume = keepAll && folder.cursor && !folder.cursor.done ? folder.cursor : null;
let lastTakenBefore: string | null = resume?.before ?? null;
let page = resume?.page ?? 1;
let pages = 0;
@@ -420,7 +427,66 @@ export async function syncImmich(
else page++;
};
for (;;) {
// The albums come first, and the whole-library read after them, because the
// column is read off the catalogue and both of these file into it: a root row
// lands the node's own number on the screen the moment it is written, and the
// album rows a moment later would be the column saying a number and then
// growing rows under it. Read this way the rows are all there before the number
// they add up to is.
if (!search && kind !== 'share') {
const albums = await listImmichAlbums(server);
const labels = albumLabels(albums);
const chosenAlbums = chosen.length ? albums.filter((a) => chosen.includes(a.id)) : albums;
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, ok } = await listAssets(server, album.id, albPage, search);
// An album that could not be read is one this reading has no word on: the
// rows it holds are left where they are, and nothing below is pruned
// against a reading that did not meet them.
if (!ok) {
failed = true;
break;
}
const albBatch: LibraryPhoto[] = [];
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);
const row: LibraryPhoto = {
id: [root, label, name].join('/'),
folder: folder.name,
dir: label,
name,
server,
remoteId: item.id,
ar: frameAr(item),
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
};
met.add(`${normPath(label)}/${name}`.toLowerCase());
albBatch.push(row);
albCount++;
}
if (albBatch.length) {
await putPhotosFast(albBatch);
onBatch?.(albBatch, { loaded: albCount, hasMore });
}
if (!hasMore || !items.length) break;
albPage++;
}
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
counts[`${root}/${label}`] = albCount;
}
}
while (keepAll) {
const { items, hasMore, ok } = await listAssets(server, null, page, search, lastTakenBefore);
// A page that could not be read is not the end of the node, and only a
// reading that met the end of it may say what the node no longer holds.
@@ -472,67 +538,19 @@ export async function syncImmich(
break;
}
}
// A reading kept to its albums met every page of every one of them: it is the
// whole of what this node is, so it is the reading allowed to say it.
if (!keepAll) complete = true;
counts[root] = rootCount;
// 2. ALBUMS of Immich ONLY display in the ALBUM section if the user chooses specific albums (chosen.length > 0).
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, ok } = await listAssets(server, album.id, albPage, search);
// An album that could not be read is one this reading has no word on: the
// rows it holds are left where they are, and nothing below is pruned
// against a reading that did not meet them.
if (!ok) {
failed = true;
break;
}
const albBatch: LibraryPhoto[] = [];
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);
const row: LibraryPhoto = {
id: [root, label, name].join('/'),
folder: folder.name,
dir: label,
name,
server,
remoteId: item.id,
ar: frameAr(item),
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
size: item.size ?? 0,
addedAt: Date.now(),
};
met.add(`${normPath(label)}/${name}`.toLowerCase());
albBatch.push(row);
albCount++;
}
if (albBatch.length) {
await putPhotosFast(albBatch);
onBatch?.(albBatch, { loaded: rootCount + albCount, hasMore });
}
if (!hasMore || !items.length) break;
albPage++;
}
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
counts[`${root}/${label}`] = albCount;
}
}
// The record carries the reading's whole word or none of it: only a reading
// that met the end of the node may say what the node holds, because a record
// that says otherwise is the record the column reads its noughts off. A reading
// stopped at the foreground window, or cut short by a page that would not come
// back, folds its half into what was already there instead.
// The node's own number, when the reading was its albums: what the rows below it
// hold between them.
if (!keepAll) counts[root] = dirs.reduce((n, d) => n + (counts[d.id] ?? 0), 0);
await saveWalkedDirs(root, dirs, counts, complete && !failed);
// A row the reading did not meet is dropped only against a reading that met all
@@ -550,7 +568,7 @@ export async function syncImmich(
// the record's exactly as the foreground's is, so a visit that ends in the
// middle of it — a reload, a closed tab, a lid shut — costs the page in flight
// and nothing else.
if ((hasMoreRemaining || failed) && !walking.has(root)) {
if (keepAll && (hasMoreRemaining || failed) && !walking.has(root)) {
walking.add(root);
void (async () => {
let bgPage = page;
+75 -15
View File
@@ -213,9 +213,35 @@ export function canBrowseFolders(): boolean {
return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
}
const THUMB_CACHE_MAX = 800;
const THUMB_CACHE_MAX = 2500;
const thumbCache = new Map<string, Blob>();
export function getCandidateIds(id: string): string[] {
const cands = new Set<string>();
cands.add(id);
const n = normPath(id);
cands.add(n);
cands.add(id.replace(/\\/g, '/'));
cands.add(id.replace(/\//g, '\\'));
cands.add(n.replace(/\\/g, '/'));
if (/^[a-zA-Z]:[/\\]/.test(id)) {
const drive = id.charAt(0);
const flippedDrive = drive === drive.toUpperCase() ? drive.toLowerCase() : drive.toUpperCase();
const flipped = flippedDrive + id.slice(1);
cands.add(flipped);
cands.add(normPath(flipped));
cands.add(flipped.replace(/\\/g, '/'));
}
if (id.startsWith('/')) {
cands.add(id.slice(1));
cands.add(normPath(id.slice(1)));
} else {
cands.add('/' + id);
cands.add(normPath('/' + id));
}
return Array.from(cands);
}
export function cacheThumb(id: string, blob: Blob): void {
if (thumbCache.has(id)) thumbCache.delete(id);
else if (thumbCache.size >= THUMB_CACHE_MAX) {
@@ -2164,15 +2190,24 @@ export function setBackupTileFetcher(fetcher: ((id: string) => Promise<Blob | nu
// screen that loses the frames it was showing — under a scan of its own, where
// the read is the thing that gives way first, that is every time.
export async function getPhotoThumb(id: string): Promise<Blob | null> {
const cached = getCachedThumb(id);
if (cached) return cached;
const candidates = getCandidateIds(id);
for (const cid of candidates) {
const cached = getCachedThumb(cid);
if (cached) {
if (cid !== id) cacheThumb(id, cached);
return cached;
}
}
try {
const db = await openDb();
if (db.objectStoreNames.contains(THUMBS)) {
const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(id));
if (row?.thumb) {
cacheThumb(id, row.thumb);
return row.thumb;
for (const cid of candidates) {
const row = await ask<{ id: string; thumb: Blob } | undefined>(THUMBS, 'readonly', (s) => s.get(cid));
if (row?.thumb) {
cacheThumb(id, row.thumb);
if (cid !== id) cacheThumb(cid, row.thumb);
return row.thumb;
}
}
}
} catch {}
@@ -2255,9 +2290,17 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
const res = new Map<string, Blob>();
const missing: string[] = [];
for (const id of ids) {
const cached = getCachedThumb(id);
if (cached) {
res.set(id, cached);
const candidates = getCandidateIds(id);
let found: Blob | null = null;
for (const cid of candidates) {
found = getCachedThumb(cid);
if (found) {
if (cid !== id) cacheThumb(id, found);
break;
}
}
if (found) {
res.set(id, found);
} else {
missing.push(id);
}
@@ -2271,20 +2314,37 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(THUMBS, 'readonly');
const store = tx.objectStore(THUMBS);
for (const id of missing) {
const req = store.get(id);
let pending = missing.length;
if (pending === 0) return resolve();
const checkNext = (id: string, variants: string[], vIdx: number) => {
if (vIdx >= variants.length) {
stillMissing.push(id);
if (--pending === 0) resolve();
return;
}
const v = variants[vIdx];
const req = store.get(v);
req.onsuccess = () => {
const row = req.result as { id: string; thumb: Blob } | undefined;
if (row?.thumb) {
cacheThumb(id, row.thumb);
if (v !== id) cacheThumb(v, row.thumb);
res.set(id, row.thumb);
if (--pending === 0) resolve();
} else {
stillMissing.push(id);
checkNext(id, variants, vIdx + 1);
}
};
req.onerror = () => {
checkNext(id, variants, vIdx + 1);
};
};
for (const id of missing) {
const variants = getCandidateIds(id);
checkNext(id, variants, 0);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} else {
stillMissing.push(...missing);