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:
@@ -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)[]> {
|
||||
|
||||
Reference in New Issue
Block a user