diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index e818f72..dcf9687 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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(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(); // One object URL per tile, made when the row does and dropped with it: a folder const globalThumbUrls = new Map(); -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 { const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]); - const [urls, setUrls] = useState>(() => { - const initial: Record = {}; + // Synchronously resolve any thumb URL already available in memory to eliminate render flicker/flashing + const syncUrls = useMemo(() => { + const res: Record = {}; 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>({}); + + const urls = useMemo(() => { + return { ...asyncUrls, ...syncUrls }; + }, [asyncUrls, syncUrls]); useEffect(() => { let alive = true; if (!list.length) { - setUrls({}); + setAsyncUrls({}); return; } - const syncUrls: Record = {}; 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 = {}; + 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(-1); + return (
e.stopPropagation()}>
@@ -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' ? {mark.label} : null} diff --git a/docker/frontend/src/engine/immich.ts b/docker/frontend/src/engine/immich.ts index c0e2d3d..262cc94 100644 --- a/docker/frontend/src/engine/immich.ts +++ b/docker/frontend/src/engine/immich.ts @@ -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(); + 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(); - 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; diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 266075b..1808b2a 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -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(); +export function getCandidateIds(id: string): string[] { + const cands = new Set(); + 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 { - 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> const res = new Map(); 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> await new Promise((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);