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>