feat(immich): draw a server as a folder in the library
A saved Immich server is a node on the column beside the drives: no handle, no picker — the key lives on the backend and the page only ever calls `/api/immich/*` on its own origin. The dialog takes the address, the name and the key, checks the key before storing anything, and offers the albums the key can see; a blank tick list and a blank id field both mean every album. A share key is one album and so has no picker. `syncImmich` turns a server's albums into the shapes the tree already draws — rows in `photos`, names in `walked` — so the column, the wall and the strip need nothing new. The node's menu is the disk's menu minus what a server has no disk for. Also fixes `saveWalkedDirs` filling its noughts in before folding the keys: a reading that spelled its keys the way it found them (a server's album labels) had the lower-case spelling of a counted key written in as nought, and the fold that settles the two spellings kept the nought.
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
// 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,
|
||||
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;
|
||||
// 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, plus the address this deployment suggests for the first one.
|
||||
// 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<{ defaultUrl: string; servers: ImmichServer[] }> | null = null;
|
||||
|
||||
export function immichConfig(fresh = false): Promise<{ defaultUrl: string; servers: ImmichServer[] }> {
|
||||
if (fresh || !configOnce) {
|
||||
configOnce = get<{ defaultUrl?: string; servers?: ImmichServer[] }>('/api/immich/config').then((body) =>
|
||||
body ? { defaultUrl: body.defaultUrl ?? '', servers: body.servers ?? [] } : { defaultUrl: '', 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
async function listAssets(
|
||||
server: string,
|
||||
album: string | null,
|
||||
page: number
|
||||
): Promise<{ items: ImmichAsset[]; hasMore: boolean }> {
|
||||
const query = new URLSearchParams({ server, page: String(page), size: String(PAGE) });
|
||||
if (album) query.set('album', album);
|
||||
const body = await get<{ items?: ImmichAsset[]; hasMore?: boolean }>(`/api/immich/assets?${query.toString()}`);
|
||||
return { items: body?.items ?? [], hasMore: body?.hasMore === true };
|
||||
}
|
||||
|
||||
// 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)}`;
|
||||
}
|
||||
|
||||
export interface ImmichSyncResult {
|
||||
total: number;
|
||||
albums: number;
|
||||
// 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[];
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// One read covers the whole node. There is no per-album read: the album list is
|
||||
// one call, a page of frames is one call, and a column that says one album is
|
||||
// fresh while the others are a week old is a column nobody can read a number off.
|
||||
export async function syncImmich(folder: LibraryFolder): Promise<ImmichSyncResult> {
|
||||
const server = folder.server ?? '';
|
||||
const albums = await listImmichAlbums(server);
|
||||
const labels = albumLabels(albums);
|
||||
const chosen = folder.albums ?? [];
|
||||
// An empty tick list is a decision and not a missing value: it means every album
|
||||
// this key can see (see the plan, the album-ID field).
|
||||
const wanted = albums.filter((a) => chosen.length === 0 || chosen.includes(a.id));
|
||||
|
||||
const rows: LibraryPhoto[] = [];
|
||||
const dirs: LibraryDir[] = [];
|
||||
const counts: Record<string, number> = {};
|
||||
const root = normPath(folder.name);
|
||||
|
||||
for (const album of wanted) {
|
||||
const label = labels.get(album.id) ?? album.id.slice(0, 8);
|
||||
// The names met in this one album. Two files of one name are two files, and
|
||||
// the id is the name, so the second of them moves rather than disappears.
|
||||
const taken = new Set<string>();
|
||||
let page = 1;
|
||||
let count = 0;
|
||||
for (;;) {
|
||||
const { items, hasMore } = await listAssets(server, album.id, page);
|
||||
for (const item of items) {
|
||||
if (!item.id || !item.name) continue;
|
||||
const name = frameName(item.name, item.id, taken);
|
||||
taken.add(item.name);
|
||||
rows.push({
|
||||
id: `${root}/${label}/${name}`,
|
||||
folder: folder.name,
|
||||
dir: label,
|
||||
name,
|
||||
server,
|
||||
remoteId: item.id,
|
||||
taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(),
|
||||
size: item.size ?? 0,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
count++;
|
||||
}
|
||||
if (!hasMore || !items.length) break;
|
||||
page++;
|
||||
}
|
||||
dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label });
|
||||
counts[`${root}/${label}`] = count;
|
||||
}
|
||||
counts[root] = rows.length;
|
||||
|
||||
await saveWalkedDirs(root, dirs, counts, true);
|
||||
// A frame the server no longer lists is not a frame to keep drawing: the row,
|
||||
// its tile and its marks go, exactly as a frame taken off the disk does. This
|
||||
// read saw the whole node, so it is the one allowed to say it.
|
||||
const met = new Set(rows.map((r) => `${normPath(r.dir)}/${r.name}`.toLowerCase()));
|
||||
await prunePhotos(root, '', met);
|
||||
await putPhotos(rows);
|
||||
|
||||
// And the tiles, for the frames that have none. A frame whose tile is already
|
||||
// kept is not asked for again, so the second sync of a roll costs the rows.
|
||||
const missing = new Set(await missingThumbs(rows.map((r) => r.id)));
|
||||
const queue = rows.filter((r) => missing.has(r.id));
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(TILE_LANES, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const row = queue.shift()!;
|
||||
try {
|
||||
const res = await fetch(remoteUrl('thumbnail', row));
|
||||
if (res.ok) await putPhotoThumb(row.id, await res.blob());
|
||||
} catch {
|
||||
// One frame's tile is one frame's: the wall draws a blank cell and the
|
||||
// reader may ask again, rather than a sync that stops halfway.
|
||||
}
|
||||
await breathe();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { total: rows.length, albums: wanted.length, dirs };
|
||||
}
|
||||
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
@@ -79,7 +79,24 @@ export interface LibraryFolder {
|
||||
name: string;
|
||||
// What the tree paints instead, when the visitor has renamed the folder.
|
||||
label?: string;
|
||||
handle: FileSystemDirectoryHandle;
|
||||
// A folder of the disk has one. A folder that is really a server — the LIBRARY's
|
||||
// Immich node — has none, and every frame under it is read over the network by
|
||||
// the routes in `immich.ts` instead of off a handle.
|
||||
handle?: FileSystemDirectoryHandle;
|
||||
// Set on a node whose frames are an Immich server's, absent on the disk's. A
|
||||
// node is one of these or the other and never both: nothing on this screen
|
||||
// offers a disk action — a rescan, a permission — on a server, and nothing
|
||||
// offers a server action on a folder.
|
||||
source?: 'immich';
|
||||
// The saved server this node reads, by the id the backend minted. Never an
|
||||
// address: an address only ever goes up once, when one is added.
|
||||
server?: string;
|
||||
// The albums this node keeps, by Immich's own id. Absent or empty is the album
|
||||
// field's empty state — every album the key can see.
|
||||
albums?: string[];
|
||||
// 'album' for a node built from album names, 'share' for one built from a share
|
||||
// link: a share link *is* one album, so it has no picker and no "Người".
|
||||
kind?: 'album' | 'share';
|
||||
}
|
||||
|
||||
export interface LibraryPhoto {
|
||||
@@ -91,7 +108,12 @@ export interface LibraryPhoto {
|
||||
// This is what the tree column reads.
|
||||
dir: string;
|
||||
name: string;
|
||||
handle: FileSystemFileHandle;
|
||||
handle?: FileSystemFileHandle;
|
||||
// The server and Immich's own uuid for this frame, on a frame whose bytes are a
|
||||
// server's rather than the disk's. Both absent together: the uuid is what every
|
||||
// route above reads the frame by, and the server says which key fetches it.
|
||||
remoteId?: string;
|
||||
server?: string;
|
||||
// The tile the catalogue kept of this frame, where one has been made: it is
|
||||
// made when the frame is first drawn and not when it is found, so a frame just
|
||||
// read has none. Null is the same as absent — a tile that could not be made.
|
||||
@@ -277,6 +299,7 @@ export async function findFolderRelationship(
|
||||
| { type: 'root' }
|
||||
> {
|
||||
for (const existing of existingFolders) {
|
||||
if (!existing.handle) continue;
|
||||
try {
|
||||
const relParts = await existing.handle.resolve(newHandle);
|
||||
if (relParts && relParts.length > 0) {
|
||||
@@ -286,6 +309,7 @@ export async function findFolderRelationship(
|
||||
}
|
||||
|
||||
for (const existing of existingFolders) {
|
||||
if (!existing.handle) continue;
|
||||
try {
|
||||
const relParts = await newHandle.resolve(existing.handle);
|
||||
if (relParts && relParts.length > 0) {
|
||||
@@ -314,6 +338,14 @@ function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore
|
||||
|
||||
let cachedFoldersList: LibraryFolder[] | null = null;
|
||||
|
||||
// A server is not a folder on a disk, and a reader who has just added one is
|
||||
// looking for it at the top of the column rather than among ten drives in the
|
||||
// order of their names. The name is what tells two of either apart, so it is the
|
||||
// only other thing this orders by.
|
||||
export function bySourceThenName(a: LibraryFolder, b: LibraryFolder): number {
|
||||
return (a.source ? 0 : 1) - (b.source ? 0 : 1) || a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
export function cachedFolders(): LibraryFolder[] {
|
||||
return cachedFoldersList ?? [];
|
||||
}
|
||||
@@ -321,7 +353,7 @@ export function cachedFolders(): LibraryFolder[] {
|
||||
export async function listFolders(): Promise<LibraryFolder[]> {
|
||||
try {
|
||||
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
|
||||
const sorted = rows.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = rows.sort(bySourceThenName);
|
||||
cachedFoldersList = sorted;
|
||||
return sorted;
|
||||
} catch {
|
||||
@@ -341,8 +373,8 @@ export async function reconnectPhotosForFolders(
|
||||
folderTargets.push({ name: newFolderHandle.name, handle: newFolderHandle });
|
||||
|
||||
const knownFolderNames = new Set([
|
||||
...existingFolders.map((f) => f.name),
|
||||
...allPhotos.map((p) => p.folder),
|
||||
...existingFolders.filter((f) => !f.source).map((f) => f.name),
|
||||
...allPhotos.filter((p) => !p.remoteId).map((p) => p.folder),
|
||||
]);
|
||||
|
||||
for (const name of knownFolderNames) {
|
||||
@@ -369,7 +401,7 @@ export async function reconnectPhotosForFolders(
|
||||
|
||||
const photosToUpdate: LibraryPhoto[] = [];
|
||||
for (const target of folderTargets) {
|
||||
const matchingPhotos = allPhotos.filter((p) => p.folder === target.name && !p.handle);
|
||||
const matchingPhotos = allPhotos.filter((p) => p.folder === target.name && !p.handle && !p.remoteId);
|
||||
if (!matchingPhotos.length) continue;
|
||||
|
||||
const dirCache = new Map<string, FileSystemDirectoryHandle>();
|
||||
@@ -422,6 +454,15 @@ export async function pickFolder(): Promise<LibraryFolder | null> {
|
||||
return folder;
|
||||
}
|
||||
|
||||
// A row that is not a folder on the disk — the LIBRARY's Immich node — is put in
|
||||
// the same store under the same key, so the column draws it beside the drives and
|
||||
// finds it again on the next visit. It is the page's own row: nothing here needs a
|
||||
// handle to hold it, and every frame under it is read by `immich.ts`.
|
||||
export async function putFolder(folder: LibraryFolder): Promise<void> {
|
||||
await ask(FOLDERS, 'readwrite', (s) => s.put(folder));
|
||||
cachedFoldersList = null;
|
||||
}
|
||||
|
||||
// A rename is a label over the folder, not a new folder: everything downstream —
|
||||
// the frame ids, the thumbnail store, the recipe each frame was left at — hangs
|
||||
// off `name`, which stays the directory's own.
|
||||
@@ -468,7 +509,7 @@ export async function removeFolder(name: string): Promise<void> {
|
||||
//
|
||||
// `from` is the folder the reading was pointed at, '' for the whole roll; `met`
|
||||
// is every frame it found, spelled relative to the roll.
|
||||
async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
|
||||
export async function prunePhotos(folder: string, from: string, met: Set<string>): Promise<void> {
|
||||
try {
|
||||
const root = normPath(folder);
|
||||
const under = (from ? `${root}/${normPath(from)}` : root).toLowerCase();
|
||||
@@ -603,6 +644,12 @@ export async function saveWalkedDirs(
|
||||
}
|
||||
}
|
||||
if (counts) {
|
||||
// Folded before the noughts below are filled in, not after: a reading that
|
||||
// spelled its keys the way it found them — a server's node, whose album
|
||||
// labels are the server's own — would have `??=` write a nought beside the
|
||||
// lower-case spelling of a key it has just counted, and the fold that
|
||||
// settles the two spellings into one would keep the nought.
|
||||
counts = fold(counts);
|
||||
// A folder the reading was in and met no frame under is a folder with
|
||||
// nothing under it: left out of the record, the row falls back on the
|
||||
// catalogue, and a frame taken off the disk keeps its number for good.
|
||||
@@ -947,6 +994,10 @@ export async function scanFolder(
|
||||
jump?: () => string | null,
|
||||
from = ''
|
||||
): Promise<ScanProgress> {
|
||||
// A server's node is not on a disk: there is nothing here to walk, and its rows
|
||||
// came from the sync in `immich.ts` rather than from a directory of files.
|
||||
const handle = folder.handle;
|
||||
if (!handle) throw new Error('no-folder-handle');
|
||||
const rawKnown = await listPhotos(folder.name);
|
||||
const known = new Map<string, LibraryPhoto>();
|
||||
for (const p of rawKnown) {
|
||||
@@ -966,7 +1017,7 @@ export async function scanFolder(
|
||||
if (p === from || p.startsWith(from)) treeWalked.delete(p);
|
||||
}
|
||||
}
|
||||
const tree: Walk = { root: folder.handle, pending: [], walked: treeWalked };
|
||||
const tree: Walk = { root: handle, pending: [], walked: treeWalked };
|
||||
// Whether this reading took up a position, read here and not after the walk:
|
||||
// `tree.walked` is this same set and the walk fills it as it goes, so a reading
|
||||
// that started from the top and one that started from a position look alike
|
||||
@@ -978,21 +1029,21 @@ export async function scanFolder(
|
||||
// picked one, or the one the reader right-clicked inside it.
|
||||
if (saved) {
|
||||
for (const rel of saved.pending) {
|
||||
const dir = await openAt(folder.handle, rel);
|
||||
const dir = await openAt(handle, rel);
|
||||
if (dir) tree.pending.push({ dir, rel });
|
||||
}
|
||||
if (from && !tree.pending.some((p) => p.rel === from)) {
|
||||
const dir = await openAt(folder.handle, from);
|
||||
const dir = await openAt(handle, from);
|
||||
if (dir) tree.pending.unshift({ dir, rel: from });
|
||||
}
|
||||
} else {
|
||||
const dir = from ? await openAt(folder.handle, from) : folder.handle;
|
||||
const dir = from ? await openAt(handle, from) : handle;
|
||||
if (dir) tree.pending.push({ dir, rel: from });
|
||||
}
|
||||
// The frames this reading has found and not read: the walk fills it, the lanes
|
||||
// empty it, and a frame taken off the front is one a reload will not read
|
||||
// again. Also where a reading that was cut off picks up.
|
||||
const entries: WalkFile[] = saved ? await framesAt(folder.handle, saved.frames) : [];
|
||||
const entries: WalkFile[] = saved ? await framesAt(handle, saved.frames) : [];
|
||||
// What the passes found: the folders this scan walked into, and the names they
|
||||
// put in the column. `names` is cleared a pass at a time; `found` keeps the
|
||||
// whole scan so a folder that has gone from the disk goes from the column too.
|
||||
@@ -1650,6 +1701,9 @@ export function jumpScan(rel: string | null): void {
|
||||
// cut off, and what a frame written over is found by.
|
||||
export function startScan(folder: LibraryFolder, from = '', mode: ScanMode = 'update'): Promise<ScanProgress> {
|
||||
if (live) return Promise.reject(new Error('a scan is already running'));
|
||||
// A server's node is not walked: its frames are read by `syncServer`, over the
|
||||
// routes, and a reading pointed at one is a reading of nothing.
|
||||
if (!folder.handle) return Promise.reject(new Error('no-folder-handle'));
|
||||
// A reading kept to one folder under the roll keeps no position of its own and
|
||||
// reads that folder however it was asked for, so only a roll's own rescan has a
|
||||
// position to throw away — and throwing away a position this reading is not
|
||||
@@ -1713,8 +1767,9 @@ export interface ScanJob {
|
||||
|
||||
interface Waiting extends ScanJob {
|
||||
// The picker's own handle, which is what a reading is started from: a name is
|
||||
// not a folder that can be walked.
|
||||
handle: FileSystemDirectoryHandle;
|
||||
// not a folder that can be walked. Absent when the request named a server's node
|
||||
// — there is nothing to walk it with, and the reading refuses itself.
|
||||
handle?: FileSystemDirectoryHandle;
|
||||
dropped: boolean;
|
||||
settle: (p: ScanProgress | null) => void;
|
||||
fail: (err: unknown) => void;
|
||||
@@ -2012,6 +2067,20 @@ export function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
async function makeOneThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
const kept = await getPhotoThumb(photo.id);
|
||||
if (kept) return kept;
|
||||
// A server's frame has no file to open: its tile is the one Immich drew, asked
|
||||
// for here and kept, so the second look comes out of the store and the sync's
|
||||
// own download is only paid once.
|
||||
if (!photo.handle && photo.remoteId) {
|
||||
try {
|
||||
const res = await fetch(remoteUrl('thumbnail', photo));
|
||||
if (!res.ok) return null;
|
||||
const made = await res.blob();
|
||||
void writeThumb(photo.id, made, false);
|
||||
return made;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!photo.handle) return null;
|
||||
try {
|
||||
const file = await readPhotoFile(photo);
|
||||
@@ -2184,9 +2253,32 @@ export async function setRotation(id: string, rot: 0 | 90 | 180 | 270): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// Where one of a frame's renditions is asked for. A frame that is a server's is
|
||||
// never fetched here by its bytes except by `readPhotoFile` below: the wall and
|
||||
// the stage take Immich's own rendition through this, which is a URL an `<img>`
|
||||
// or a blob fetch can both use, and which the proxy answers with a day of cache.
|
||||
export const IMMICH_SIZES = ['thumbnail', 'preview', 'fullsize'] as const;
|
||||
export type ImmichSize = (typeof IMMICH_SIZES)[number];
|
||||
|
||||
export function remoteUrl(size: ImmichSize | 'original', photo: { server?: string; remoteId?: string }): string {
|
||||
const route = size === 'original' ? 'original' : 'thumb';
|
||||
const params = new URLSearchParams({ server: photo.server ?? '', id: photo.remoteId ?? '' });
|
||||
if (size !== 'original') params.set('size', size);
|
||||
return `/api/immich/${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// The frame itself, at last: nothing is read from the disk until this runs, so
|
||||
// the catalogue costs thumbnails and no more.
|
||||
// the catalogue costs thumbnails and no more. A frame that is a server's is read
|
||||
// over the proxy, whole — it is the largest thing this app ever asks Immich for,
|
||||
// which is why only opening the studio asks it.
|
||||
export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
|
||||
if (photo.remoteId) {
|
||||
const res = await fetch(remoteUrl('original', photo));
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const blob = await res.blob();
|
||||
return new File([blob], photo.name, { type: blob.type || 'application/octet-stream' });
|
||||
}
|
||||
if (!photo.handle) throw new Error('no-file');
|
||||
return photo.handle.getFile();
|
||||
}
|
||||
|
||||
@@ -2244,6 +2336,14 @@ export async function listEdits(): Promise<LibraryEdit[]> {
|
||||
// is what lets the walk that follows match every frame and read none of them.
|
||||
export type CatalogueRow = Omit<LibraryPhoto, 'handle' | 'thumb'>;
|
||||
|
||||
// Put rows in the catalogue with no backup behind them — the same write
|
||||
// `restoreCatalogue` does, for a source whose rows came from somewhere other than
|
||||
// a disk this page walked. Every row goes in as it came; a row already here is
|
||||
// replaced by the newer word on it, which is what a sync is.
|
||||
export async function putPhotos(rows: CatalogueRow[]): Promise<number> {
|
||||
return restoreCatalogue(rows, []);
|
||||
}
|
||||
|
||||
// The rows a set of ids stands for, gaps left as gaps — one transaction for the
|
||||
// lot, where `getPhoto` would be one per frame.
|
||||
async function rowsAt(ids: string[]): Promise<(LibraryPhoto | undefined)[]> {
|
||||
|
||||
@@ -316,7 +316,13 @@ export interface BackupResult extends BackupStatus {
|
||||
|
||||
export async function backupNow(onProgress?: (p: BackupProgress) => void): Promise<BackupResult> {
|
||||
const root = await writable();
|
||||
const [photos, edits, folders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
|
||||
const [all, edits, allFolders] = await Promise.all([listPhotos(), listEdits(), listFolders()]);
|
||||
// The copy is the disk's: a node that is really an Immich server and the frames
|
||||
// read over the network from it are not on this machine, so they are not part of
|
||||
// what a folder on this machine can hold. The catalogue still keeps them — see
|
||||
// `immich.ts`, which reads them again on the next visit.
|
||||
const photos = all.filter((p) => !p.remoteId);
|
||||
const folders = allFolders.filter((f) => !f.source);
|
||||
const had = await already();
|
||||
const at0 = Date.now();
|
||||
const cat = {
|
||||
|
||||
Reference in New Issue
Block a user