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);
+40 -1
View File
@@ -26,10 +26,14 @@ const KEY_NODL = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbb1234'; // the same key without ass
const SHARE_KEY = 'share-key-1234567890';
const ALBUM_A = '11111111-1111-4111-8111-111111111111';
const ALBUM_B = '22222222-2222-4222-8222-222222222222';
const TAG_HOME = '33333333-3333-4333-8333-333333333333';
const TAG_TRIP = '44444444-4444-4444-8444-444444444444';
const assetId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
const THUMB = Buffer.from('RIFF0000WEBPfake-tile-bytes');
const ORIGINAL = Buffer.from('II*\0fake-original-bytes');
// The first frame is the one tagged `Nhà`, so a search for that word has exactly
// one answer to find.
const asset = (n, album) => ({
id: assetId(n),
type: 'IMAGE',
@@ -39,6 +43,7 @@ const asset = (n, album) => ({
height: 3000,
exifInfo: { fileSizeInByte: 1000 * n },
album,
tags: n === 1 ? TAG_HOME : n === 2 ? TAG_TRIP : null,
});
let pass = 0;
@@ -103,9 +108,27 @@ function startFake() {
]);
}
// A tag list, and the one key that may not read it: searching by tag is the
// one search a narrow key loses, and it has to lose it as an empty list
// rather than as a failed search.
if (url.pathname === '/api/tags') {
if (!authed && !shareAuthed) return send(401, { message: 'Authentication required' });
if (apiKey === KEY_NODL) return send(403, { message: 'Missing required permission: tag.read' });
return send(200, [
{ id: TAG_HOME, name: 'Nhà', value: 'nhà' },
{ id: TAG_TRIP, name: 'du lịch', value: 'du lịch' },
]);
}
if (url.pathname === '/api/search/metadata') {
const all = [asset(1, ALBUM_A), asset(2, ALBUM_A), asset(3, ALBUM_B)];
const filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all;
let filtered = Array.isArray(body.albumIds) ? all.filter((a) => body.albumIds.includes(a.album)) : all;
// The same reading the proxy makes of a search box: every field given is
// one more thing an asset must be.
if (Array.isArray(body.tagIds)) filtered = filtered.filter((a) => body.tagIds.includes(a.tags));
if (typeof body.originalFileName === 'string') {
filtered = filtered.filter((a) => a.originalFileName.includes(body.originalFileName));
}
const size = Number(body.size ?? 100);
const page = Number(body.page ?? 1);
const items = filtered.slice((page - 1) * size, page * size);
@@ -301,6 +324,16 @@ try {
const badAlbum = await user.req(`/immich/assets?server=${serverId}&album=nope`);
check('a malformed album id is refused', badAlbum.status === 400, `got ${badAlbum.status}`);
// ---- the search box ------------------------------------------------------
const byName = await user.req(`/immich/assets?server=${serverId}&q=photo-2`);
check('a search reads the words as a file name', byName.body?.items?.length === 1 && byName.body?.items?.[0]?.name === 'photo-2.jpg', JSON.stringify(byName.body?.items));
const byTag = await user.req(`/immich/assets?server=${serverId}&q=${encodeURIComponent('Nhà')}`);
check('a search that names a tag finds that tag', byTag.body?.items?.length === 1 && byTag.body?.items?.[0]?.name === 'photo-1.jpg', JSON.stringify(byTag.body?.items));
check('the tag ids it found travel as tag ids', seen.at(-1)?.path === '/api/search/metadata', JSON.stringify(seen.at(-1)));
const noAnswer = await user.req(`/immich/assets?server=${serverId}&q=nothing-at-all`);
check('a search that answers nothing is an empty page, not a failure', noAnswer.status === 200 && noAnswer.body?.items?.length === 0 && noAnswer.body?.hasMore === false, JSON.stringify(noAnswer.body));
// ---- the pixels ---------------------------------------------------------
const tile = await user.req(`/immich/thumb?server=${serverId}&id=${assetId(1)}`);
check('a tile streams through the proxy', tile.status === 200 && tile.body.equals(THUMB), `got ${tile.status}`);
@@ -329,6 +362,10 @@ try {
check('a key without download is still accepted', noDownload.status === 200 && noDownload.body?.server?.canDownload === false, JSON.stringify(noDownload.body));
const refused = await user.req(`/immich/original?server=${readerId}&id=${assetId(1)}`);
check('its original is refused with the reason', refused.status === 502 && refused.body?.error === 'auth', JSON.stringify(refused.body));
// The same narrow key searching by a tag name it cannot read: the 403 on the
// tag list is an empty list of tags, not a search that came back broken.
const narrowSearch = await user.req(`/immich/assets?server=${readerId}&q=${encodeURIComponent('Nhà')}`);
check('a key without tag.read still searches by file name', narrowSearch.status === 200 && narrowSearch.body?.items?.length === 0, JSON.stringify(narrowSearch.body));
// ---- a share link is one album ------------------------------------------
const shareProbe = await user.post('/immich/probe', { url, key: SHARE_KEY, type: 'share' });
@@ -339,6 +376,8 @@ try {
check('a share server is stored', shareSaved.status === 200 && typeof shareId === 'string', JSON.stringify(shareSaved.body));
const shareAssets = await user.req(`/immich/assets?server=${shareId}&page=1&size=10`);
check('a share key lists its album whole', shareAssets.body?.items?.length === 2, JSON.stringify(shareAssets.body));
const shareSearch = await user.req(`/immich/assets?server=${shareId}&q=photo-5`);
check('a share link searches its one album', shareSearch.body?.items?.length === 1 && shareSearch.body?.items?.[0]?.name === 'photo-5.jpg', JSON.stringify(shareSearch.body?.items));
const shareTile = await user.req(`/immich/thumb?server=${shareId}&id=${assetId(5)}`);
check('a share tile streams too', shareTile.status === 200 && shareTile.body.equals(THUMB), `got ${shareTile.status}`);
check('a share request travels with ?key=', seen.at(-1)?.shareKey === SHARE_KEY, JSON.stringify(seen.at(-1)));