// Immich, as a second photo source beside the folders on disk — the page's half. // // Nothing here talks to Immich: the key never reaches the browser, and the // address never leaves the backend, so every call below is one of the routes in // `backend/src/immich.ts` on this page's own origin. What this file does is turn // a server's answer into the two shapes the LIBRARY already knows — a row in the // `photos` store and a name in the column — so the tree, the wall and the strip // draw a server's frames with the code they already had for a folder's. import { breathe, missingThumbs, normPath, prunePhotos, putFolder, putPhotoThumb, putPhotos, putPhotosFast, remoteUrl, saveWalkedDirs, type LibraryDir, type LibraryFolder, type LibraryPhoto, } from './library'; // Immich answers 200 items at most for one page of a search (the backend caps it // there), so this is the size the sync loops at: one round trip per two hundred // frames rather than one per frame. const PAGE = 200; // How many times one page of a server's frames is asked for again before the // reading gives up on it. A page in flight is a call to a box the reader's own // network may be between, and the second answer is usually the first answer. const ASSET_RETRIES = 2; // The nodes one reading is walking through. The screen asks for a sync when it // opens and whenever the node it shows changes, and two readings of one node at // the same time are two readings asking the server for the same pages and writing // one place over the other: the second is turned away, and the first keeps the // place for both. const walking = new Set(); // Tiles are drawn after the rows have landed rather than before: the column and // the count are what the reader sees first, and a tile is the wall's own business. const TILE_LANES = 3; export interface ImmichAlbum { id: string; albumName: string; assetCount: number; coverId: string | null; shared: boolean; } export interface ImmichAsset { id: string; name: string; takenAt: string | null; width: number | null; height: number | null; size: number | null; } export interface ImmichServer { id: string; name: string; url: string; type: 'api' | 'share'; version: string | null; canDownload: boolean | null; albums: string[]; keyMasked: string; } // The three refusals the dialog can say something about, plus the one it can only // say as a shape: the key is wrong or too narrow, the box cannot be reached, it // answered with something unusable, or what was typed is not an address and a key. export type ImmichError = 'auth' | 'unreachable' | 'server' | 'invalid'; export type ImmichProbe = | { ok: true; version: string | null; albums: ImmichAlbum[]; canDownload: boolean | null } | { ok: false; error: ImmichError }; export type ImmichSaved = { ok: true; server: ImmichServer } | { ok: false; error: ImmichError }; // One place reads a JSON answer, so a route that answered with a page of HTML — // a proxy in the way, a box that is not Immich — is a refusal rather than a crash // somewhere further in. async function json(res: Response): Promise { try { return (await res.json()) as T; } catch { return null; } } async function get(path: string): Promise { try { return await json(await fetch(path)); } catch { return null; } } // The saved servers. One call per session: the tree has to draw on the first // paint, and a server list that is asked for on every render is a round trip per // render. let configOnce: Promise<{ servers: ImmichServer[] }> | null = null; export function immichConfig(fresh = false): Promise<{ servers: ImmichServer[] }> { if (fresh || !configOnce) { configOnce = get<{ servers?: ImmichServer[] }>('/api/immich/config').then((body) => ({ servers: body?.servers ?? [], })); } return configOnce; } export function forgetImmichConfig(): void { configOnce = null; } // "Check this key" — nothing is stored, so a typo never becomes a row. export async function probeImmich(url: string, key: string, type: 'api' | 'share'): Promise { try { const res = await fetch('/api/immich/probe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url, key, type }), }); const body = await json(res); return body ?? { ok: false, error: 'server' }; } catch { return { ok: false, error: 'unreachable' }; } } // Add a server, or change one. The backend probes before it stores, so a server // that comes back saved is one that answered — `albums` is the ticked set, sent // on the same call that saves the key so a fresh server is never briefly "all". export async function saveImmichServer(input: { id?: string; name?: string; url?: string; key?: string; type?: 'api' | 'share'; albums?: string[]; }): Promise { try { const res = await fetch('/api/immich/config', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input), }); const body = await json(res); if (body?.ok) forgetImmichConfig(); return body ?? { ok: false, error: 'server' }; } catch { return { ok: false, error: 'unreachable' }; } } export async function forgetImmichServer(id: string): Promise { try { const res = await fetch(`/api/immich/config?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); if (res.ok) forgetImmichConfig(); return res.ok; } catch { return false; } } // What to say when the upload was refused. The wrapper's own summary ("Immich // upload failed") names nothing anyone can act on; Immich's answer underneath // does — `{"message":"Missing required permission: asset.upload"}` is the error // AND the fix, so it is what the dialog shows. The details are handed over as // they came when they are not JSON, since a proxy's page is then the only clue // there is. function immichReason(body: { error?: string; details?: string }, status: number): string { const details = (body.details ?? '').trim(); if (details) { try { const message = (JSON.parse(details) as { message?: string }).message; if (message) return String(message).slice(0, 300); } catch { /* not JSON — hand the text over as it stands */ } return details.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300) || `HTTP ${status}`; } return body.error || `HTTP ${status}`; } // Upload an edited photo to Immich Cloud export async function uploadToImmich( bytes: Uint8Array, name: string, serverId?: string, albumId?: string ): Promise<{ ok: boolean; id?: string; error?: string }> { try { const params = new URLSearchParams(); if (serverId) params.set('server', serverId); if (name) params.set('name', name); if (albumId) params.set('album', albumId); const res = await fetch(`/api/immich/upload?${params.toString()}`, { method: 'POST', headers: { 'content-type': 'image/jpeg', }, body: bytes as BodyInit, }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string; details?: string }; return { ok: false, error: immichReason(err, res.status) }; } const data = await res.json().catch(() => ({})); return { ok: true, id: (data as any)?.id }; } catch (err: any) { return { ok: false, error: err?.message || String(err) }; } } // The albums this key can see, asked for live: an album added on Immich has to be // there the next time the dialog is opened, so nothing here is cached. export async function listImmichAlbums(server: string): Promise { const body = await get<{ ok?: boolean; albums?: ImmichAlbum[] }>( `/api/immich/albums?id=${encodeURIComponent(server)}` ); return body?.albums ?? []; } export async function saveImmichAlbums(server: string, selected: string[]): Promise { try { const res = await fetch('/api/immich/albums', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: server, selected }), }); const body = await json<{ albums?: string[] }>(res); return body?.albums ?? []; } catch { return []; } } // One page of a server's frames. `album` is an Immich id or null for everything // the key can see — the album field's empty state. export async function listAssets( server: string, album: string | null, page: number, search = '', takenBefore?: string | null, takenAfter?: string | null ): Promise<{ items: ImmichAsset[]; hasMore: boolean; ok: boolean }> { const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) }); if (album) query.set('album', album); if (search) query.set('q', search); if (takenBefore) query.set('takenBefore', takenBefore); if (takenAfter) query.set('takenAfter', takenAfter); // A page that could not be read is not a page with nothing on it. Both are one // shape to a loop that counts items, and the loop that takes the one for the // other reads the end of the roll at the first timeout and drops every row it // had not met yet. `ok` is the word that says which of the two this was, and an // unanswered page is asked for again here rather than in every caller. for (let attempt = 0; ; attempt++) { try { const res = await fetch(`/api/immich/assets?${query.toString()}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await json<{ items?: ImmichAsset[]; hasMore?: boolean }>(res); if (!body) throw new Error('unreadable answer'); return { items: body.items ?? [], hasMore: body.hasMore === true, ok: true }; } catch { if (attempt >= ASSET_RETRIES) return { items: [], hasMore: false, ok: false }; await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))); } } } // Fetch timeline buckets (months/years) for an Immich server in one fast call export async function fetchImmichBuckets( server: string, size: 'YEAR' | 'MONTH' = 'MONTH' ): Promise<{ timeBucket: string; count: number }[]> { const query = new URLSearchParams({ server, size }); const body = await get<{ buckets?: { timeBucket: string; count: number }[] }>( `/api/immich/buckets?${query.toString()}` ); return Array.isArray(body?.buckets) ? body.buckets : []; } // What the tree paints, and the name an album's frames are filed under. An album // with no name — a key too narrow to read one, an album the reader never titled — // is known by the first of its id, which is what the plan asks for. function albumLabels(albums: ImmichAlbum[]): Map { const taken = new Set(); const out = new Map(); for (const album of albums) { let label = album.albumName.trim() || album.id.slice(0, 8); // Two albums may carry one name — Immich allows it — and a column is drawn // off the path, so a second of that name would be a row the first one already // is. The uuid is what tells them apart, and eight of it is enough to read. if (taken.has(label)) label = `${label} ~${album.id.slice(0, 8)}`; taken.add(label); out.set(album.id, label); } return out; } // The `~` suffix a frame takes when an album holds two files of one name: the id // is the name, so one of them has to move rather than one of them disappear. function frameName(name: string, id: string, taken: Set): string { if (!taken.has(name)) return name; return `${name} ~${id.slice(0, 8)}`; } // The shape a frame stands up in, off the numbers Immich holds of it rather than // off its own pixels: the wall can be laid out from these the moment the rows // land, where a frame whose shape is not known has to wait for its picture and // move every row below it when it does. Immich states neither on every frame, and // a frame of either number missing is one the wall measures itself. function frameAr(asset: ImmichAsset): number | undefined { return asset.width && asset.height ? asset.width / asset.height : undefined; } export interface ImmichSyncResult { total: number; albums: number; // False while the node is still being read — the answer above comes back at the // foreground window, and the rest of it is read behind it. The screen says so // rather than a number that would have the reader believe the node holds six // hundred frames. done: boolean; // The album rows this read named, spelled the way the column spells a path. The // screen puts these in the place of the node's old ones: an album the reader // unticked is a row that has to go, and a record that is only ever added to // would keep drawing it. dirs: LibraryDir[]; } export type ImmichSyncCallback = (batch: LibraryPhoto[], progress: { loaded: number; hasMore: boolean }) => void; // Read a server's albums into the catalogue: the rows and the column first, so // the count and the tree are right the moment this returns, and the tiles after, // a lane at a time, so a reader who opens the node while it is still fetching // sees frames rather than a blank wall. // // Streams batches to IndexedDB and UI immediately (in < 200ms per batch). // Resolves the foreground pass in 3 pages (up to 600 newest photos) so the UI // unblocks instantly, and continues syncing historical photos in the background. export async function syncImmich( folder: LibraryFolder, onBatch?: ImmichSyncCallback ): Promise { 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 // the whole reading that is written down here — the foreground's pages and the // pages behind them — because a node bigger than one visit is read in two // passes, and a prune held against the first pass alone takes back every frame // the second one filed: the node the reader is left with is the newest three // pages of it, for good, and its place says the reading is done. const met = new Set(); const dirs: LibraryDir[] = []; const counts: Record = {}; // The frames this reading has already filed. The keyset read stops on a frame's // own time and `takenBefore` is inclusive, so every page opens on the frame the // page before it ended with: filed as it came, that frame is a row the wall // draws twice. `frameName` answers the name a second *file* takes; the id is // what says there is no second file. const seen = new Set(); // 1. ALWAYS read ALL photos of the Immich API (filed under root of folder: dir = '') // Keyset pagination with takenBefore prevents slow SQL offsets and timeouts! const rootTaken = new Set(); // Where the reading before this one stopped, when it did not reach the end. A // node of a hundred thousand frames is not one visit's to read, and a place only // ever held in memory is a place every reload began without: the whole of what // 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 = keepAll && folder.cursor && !folder.cursor.done ? folder.cursor : null; let lastTakenBefore: string | null = resume?.before ?? null; let page = resume?.page ?? 1; let pages = 0; let rootCount = 0; let complete = false; let failed = false; const FOREGROUND_PAGES = 3; // The newest three pages answer the call so the screen unblocks let hasMoreRemaining = false; // The place, written down where the visit after this one reads it. Every page // rather than every minute: a page is two hundred frames, and a visit may end // at any of them. const remember = async (before: string | null, at: number, done: boolean) => { try { await putFolder({ ...folder, cursor: { before, page: at, done } }); } catch {} }; // Where a page leaves the reading. The bound is a frame's own instant and the // call that asks for it keeps the frame it stopped on — `takenBefore` is // inclusive — so a page whose oldest frame is older than the bound has moved it, // and the page after it starts at that very frame. A page that is one instant // from end to end cannot move it at all: Immich keeps a frame's time to the // millisecond, and a roll imported in one go is hundreds of frames at the same // one, which is the whole of the oldest days of this library. Asked for with the // bound alone, that page is the same page for ever — the reading never ends, and // the frames under that instant are never read. So the rest of the instant is // walked by offset, and the bound is left where it is until the pages under it // run out. // ponytail: that offset walk is over everything older than the bound, and a deep // offset is a slow answer — an Immich that offers a cursor for it, or a page // asked per instant out of the timeline buckets, is the way out of it if a roll // of thousands at one instant ever sits in the middle of a library. const step = (items: ImmichAsset[]): void => { const at = items[items.length - 1]?.takenAt ?? null; if (page === 1 && at && at !== lastTakenBefore) lastTakenBefore = at; else page++; }; // 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. if (!ok) { failed = true; break; } const batchRows: LibraryPhoto[] = []; for (const item of items) { if (!item.id || !item.name || seen.has(item.id)) continue; seen.add(item.id); const name = frameName(item.name, item.id, rootTaken); rootTaken.add(item.name); rootTaken.add(name); const row: LibraryPhoto = { id: [root, name].join('/'), folder: folder.name, dir: '', 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(name.toLowerCase()); batchRows.push(row); rootCount++; } // Save batch to IndexedDB and push to UI immediately if (batchRows.length) { await putPhotosFast(batchRows); onBatch?.(batchRows, { loaded: rootCount, hasMore }); } pages++; if (!hasMore || !items.length) { complete = true; break; } step(items); await remember(lastTakenBefore, page, false); // After foreground window, resolve foreground sync promptly so UI is responsive if (pages >= FOREGROUND_PAGES) { hasMoreRemaining = true; 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; // 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 // of them — and only one that began at the top. A reading that carried on from // where the last one stopped met the older pages alone and not one of the newest, // and pruned to what it met it would take back every frame it was picking up. // ponytail: a resumed reading therefore stops one prune short — a frame dropped // on the server keeps its row until some visit reads the node from the top. Read // the catalogue rather than the reading if that ever shows. if (complete && !failed && !resume) { await prunePhotos(root, '', met); } // The rest of the node, a page at a time, behind the answer above. Its place is // 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 (keepAll && (hasMoreRemaining || failed) && !walking.has(root)) { walking.add(root); void (async () => { let bgPage = page; let bgLastTakenBefore = lastTakenBefore; let reachedEnd = false; try { for (;;) { const { items, hasMore: bgMore, ok } = await listAssets( server, null, bgPage, search, bgLastTakenBefore ); // A page that could not be read leaves the place where the last page // that could left it: the visit after this one asks for it again. if (!ok) break; if (!items.length) { reachedEnd = true; break; } const bgBatchRows: LibraryPhoto[] = []; for (const item of items) { if (!item.id || !item.name || seen.has(item.id)) continue; seen.add(item.id); const name = frameName(item.name, item.id, rootTaken); rootTaken.add(item.name); rootTaken.add(name); met.add(name.toLowerCase()); bgBatchRows.push({ id: [root, name].join('/'), folder: folder.name, dir: '', 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(), }); rootCount++; } if (bgBatchRows.length) { await putPhotosFast(bgBatchRows); onBatch?.(bgBatchRows, { loaded: rootCount, hasMore: bgMore }); } if (!bgMore) { reachedEnd = true; break; } const at = items[items.length - 1]?.takenAt ?? null; if (bgPage === 1 && at && at !== bgLastTakenBefore) bgLastTakenBefore = at; else bgPage++; await remember(bgLastTakenBefore, bgPage, false); await breathe(); } counts[root] = rootCount; await saveWalkedDirs(root, dirs, counts, reachedEnd); if (reachedEnd) { // The node is read through: the place is not a place any more, and the // reading that begins next begins at the top — which is where a frame // added since would be. await remember(null, 1, true); if (!resume) { await prunePhotos(root, '', met); } } } finally { // The reading's last word. The answer `syncImmich` gave was the // foreground window's, and the pages behind it are read after it: without // this the screen goes on saying a reading is running for the rest of the // visit, and a reading nobody is doing is the note the reader reads as a // reading that never stops. A walk that ended at a page the server would // not answer says the same thing — what the catalogue was given is what it // was given — and the place stays where it stopped either way. onBatch?.([], { loaded: rootCount, hasMore: false }); walking.delete(root); } })(); } return { total: rootCount, albums: dirs.length, dirs, done: complete && !failed }; } // The name a server's node takes: the first one is `Immich`, and a second is // numbered, because the name is the store's key and two keys cannot be one. export function immichFolderName(taken: Iterable): string { const used = new Set(taken); let n = 1; while (used.has(n === 1 ? 'Immich' : `Immich ${n}`)) n++; return n === 1 ? 'Immich' : `Immich ${n}`; } // What the column paints for that node: the source and the name its owner gave it. export function immichFolderLabel(serverName: string): string { return `Immich · ${serverName}`; } // Put a saved server in the column as a folder. The row is the page's own — a // name, the server it reads, and the albums it keeps — and `taken` is what it must // not collide with, because the name is the store's key and two keys cannot be // one. The name is `Immich`, `Immich 2`, …; the label is what the tree paints. // The name a search node takes. The query is in it because the name is what the // node is: a column of `Immich · …` rows would say nothing about which search is // which, and the name is the store's key, so the same words are already the same // node. Only a clash with something that is not this query's own node — a disk // folder someone called the same — moves it on to a number. export function immichSearchName(q: string, taken: Iterable): string { const used = new Set(taken); const base = `Immich · ${q}`; let name = base; for (let n = 2; used.has(name); n++) name = `${base} ${n}`; return name; } // Put a search in the column as a node of its own. The same words are the same // node read again rather than one more row for every time they are typed: the // frames are keyed by it, and a node per attempt would be a column of dead ends. export async function searchImmichFolder( server: ImmichServer, query: string, current: LibraryFolder[] ): Promise { // Bounded the way the backend bounds it, so the name and the query are the same // words: a row whose name is longer than the query that made it is a row nobody // can match up again. const q = query.trim().slice(0, 100); const found = current.find((f) => f.kind === 'search' && f.server === server.id && f.query === q); if (found) return found; const folder: LibraryFolder = { name: immichSearchName(q, current.map((f) => f.name)), label: `Immich · ${server.name} · “${q}”`, source: 'immich', server: server.id, kind: 'search', query: q, }; await putFolder(folder); return folder; } export async function addImmichFolder(server: ImmichServer, taken: Iterable): Promise { const folder: LibraryFolder = { name: immichFolderName(taken), label: immichFolderLabel(server.name), source: 'immich', server: server.id, albums: server.albums, kind: server.type === 'share' ? 'share' : 'album', }; await putFolder(folder); return folder; }