cfdebab157
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.
726 lines
29 KiB
TypeScript
726 lines
29 KiB
TypeScript
// 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<string>();
|
|
// 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<T>(res: Response): Promise<T | null> {
|
|
try {
|
|
return (await res.json()) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function get<T>(path: string): Promise<T | null> {
|
|
try {
|
|
return await json<T>(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<ImmichProbe> {
|
|
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<ImmichProbe>(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<ImmichSaved> {
|
|
try {
|
|
const res = await fetch('/api/immich/config', {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(input),
|
|
});
|
|
const body = await json<ImmichSaved>(res);
|
|
if (body?.ok) forgetImmichConfig();
|
|
return body ?? { ok: false, error: 'server' };
|
|
} catch {
|
|
return { ok: false, error: 'unreachable' };
|
|
}
|
|
}
|
|
|
|
export async function forgetImmichServer(id: string): Promise<boolean> {
|
|
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<ImmichAlbum[]> {
|
|
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<string[]> {
|
|
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<string, string> {
|
|
const taken = new Set<string>();
|
|
const out = new Map<string, string>();
|
|
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>): 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<ImmichSyncResult> {
|
|
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<string>();
|
|
const dirs: LibraryDir[] = [];
|
|
const counts: Record<string, number> = {};
|
|
// 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<string>();
|
|
|
|
// 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<string>();
|
|
// 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<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.
|
|
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>): 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>): 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<LibraryFolder> {
|
|
// 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<string>): Promise<LibraryFolder> {
|
|
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;
|
|
}
|