fix(library): a reading that never finished no longer says nought

`saveWalkedDirs` filled in a nought beside every folder a reading had listed but
never opened. A reading stopped by a closed tab, and one kept to a branch, list
the rest of the roll before they have been into any of it — 484 folders of one
real roll read empty over tens of thousands of frames — and the record is folded
back into itself, so the noughts stayed.

Only a reading that met the whole roll may say nought now (`WalkedDirs.full`), and
the screen drops a nought from a record that does not say so. An old record has no
word to give, so its rows fall back on the catalogue instead.

feat(library): search Immich from the filter row

One box, one query, one node: the backend reads what is typed as a tag name when
the key can see one and as a file name otherwise, and the answer is filed under the
node itself. A share link searches its one album in place. The box is not a filter —
it leaves the shelf, which is what the md's search permission is for.

feat(ui): a shorter status line, and a wall that keeps its own row height

The status line is one short 12px row at the foot of the library. A wall with fewer
frames than there are columns no longer stretches its one row — and the cards' own
borders with it — down to the stage.
This commit is contained in:
2026-10-10 18:27:49 +07:00
parent edd7b8497b
commit b3b9ed0193
8 changed files with 267 additions and 34 deletions
+38 -2
View File
@@ -19,6 +19,9 @@ const MAX_KEY = 400;
const MAX_NAME = 60;
const MAX_ALBUMS = 200;
const MAX_PAGE_SIZE = 200;
// What the shelf's search box may carry. Bounded because it goes on to another
// box as a query, and a search box is a thing people paste into.
const MAX_QUERY = 100;
// A server is a box the operator may not control, and a hang there must not
// become a hung LIBRARY. Streaming an original is the one call that legitimately
@@ -201,6 +204,18 @@ async function listAlbums(server: ImmichServer): Promise<AlbumRow[] | Failure> {
return ((await jsonOf<RawAlbum[]>(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album));
}
// The tags this key can see whose name holds what was typed. Asked for on every
// search rather than cached: a tag made on Immich has to be found by the next
// one, and the list is small. A key without `tag.read` is answered 403, which is
// a key that cannot search by tag — not a search that failed.
async function tagIdsMatching(server: ImmichServer, q: string): Promise<string[]> {
const res = await call(server, { path: '/tags' });
if (!res.ok) return [];
const tags = (await jsonOf<{ id?: string; name?: string }[]>(res)) ?? [];
const needle = q.toLowerCase();
return tags.filter((tag) => tag.id && (tag.name ?? '').toLowerCase().includes(needle)).map((tag) => tag.id as string);
}
async function firstAssetId(server: ImmichServer): Promise<string | null> {
if (server.type === 'share') {
const res = await call(server, { path: '/shared-links/me' });
@@ -389,12 +404,19 @@ export function immichRoutes(
const size = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(Number(q.size ?? 100)) || 100));
const album = q.album && q.album !== 'all' ? q.album : null;
if (album && !UUID_RE.test(album)) return reply.status(400).send({ error: 'invalid album id' });
// What the shelf's search box carries: words to look for in a file's own name,
// or the name of a tag. A share link has no search of its own, so its one
// album is sifted here instead.
const search = (q.q ?? '').trim().slice(0, MAX_QUERY);
if (server.type === 'share') {
const res = await call(server, { path: '/shared-links/me' });
if (!res.ok) return reply.status(502).send(refusal(res));
const me = await jsonOf<{ assets?: RawAsset[] }>(res);
const all = (me?.assets ?? []).filter((a) => a.type === 'IMAGE' || a.type === undefined);
const needle = search.toLowerCase();
const all = (me?.assets ?? [])
.filter((a) => a.type === 'IMAGE' || a.type === undefined)
.filter((a) => !needle || (a.originalFileName ?? '').toLowerCase().includes(needle));
const start = (page - 1) * size;
return reply.send({
ok: true,
@@ -406,10 +428,24 @@ export function immichRoutes(
});
}
// One query, one answer. Immich reads every field it is handed as one more
// thing an asset must be — `originalFileName` with `tagIds` is a file called
// this *and* tagged that — so the box is read as a tag when it names one, and
// as a file name otherwise, which keeps the page a page of one list.
// ponytail: a query that names a tag finds only that tag's frames; fold the
// two answers into one list if a reader ever needs both at once.
const tagIds = search ? await tagIdsMatching(server, search) : [];
const res = await call(server, {
method: 'POST',
path: '/search/metadata',
json: { size, page, withExif: true, type: 'IMAGE', ...(album ? { albumIds: [album] } : {}) },
json: {
size,
page,
withExif: true,
type: 'IMAGE',
...(album ? { albumIds: [album] } : {}),
...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}),
},
});
if (!res.ok) return reply.status(502).send(refusal(res));
const found = await jsonOf<{ assets?: { items?: RawAsset[]; total?: number } }>(res);