feat: the camera's own colour, a reading that keeps its place, and cloud links in Settings

- RAW opens at the colour the camera wrote into it: the develop carries the
  difference against the file's own preview (previewResidual) instead of the
  per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid.
- Immich reading: keyset position kept across visits, one reading per node,
  a page in flight retried, timeline buckets for the years on offer.
- The wall draws a server frame at the address the proxy serves it (no blob
  minted for it), and the 80-thumbnail pre-cache on open is gone.
- Settings lists the cloud links, flags two that answer at the same address,
  and forgets one.
- Custom frames imported from a file (engine/customFrame.ts, IndexedDB).
- Upload an edited frame from Studio to Immich Cloud.
This commit is contained in:
2026-10-12 07:59:24 +07:00
parent 807c271b6b
commit 97fd181cfa
25 changed files with 2402 additions and 616 deletions
+115 -2
View File
@@ -287,7 +287,7 @@ export function immichRoutes(
// client sends can aim this proxy at a host that is not in its own list.
const pick = (user: User, reply: FastifyReply, id: unknown): ImmichServer | undefined => {
const servers = immichServers(user.id);
const server = typeof id === 'string' ? servers.find((s) => s.id === id) : undefined;
const server = typeof id === 'string' && id ? servers.find((s) => s.id === id) : servers[0];
if (!server) {
void reply.status(404).send({ error: 'unknown server' });
return undefined;
@@ -458,12 +458,15 @@ export function immichRoutes(
const res = await call(server, {
method: 'POST',
path: '/search/metadata',
timeout: 60_000,
json: {
size,
page,
withExif: true,
type: 'IMAGE',
...(album ? { albumIds: [album] } : {}),
...(q.takenBefore ? { takenBefore: q.takenBefore } : {}),
...(q.takenAfter ? { takenAfter: q.takenAfter } : {}),
...(search ? (tagIds.length ? { tagIds } : { originalFileName: search }) : {}),
},
});
@@ -475,10 +478,15 @@ export function immichRoutes(
// Raw array: Array.isArray(found)
const rawItems: RawAsset[] = found?.assets?.items ?? found?.items ?? (Array.isArray(found) ? found : []);
const total: number | null = found?.assets?.total ?? found?.total ?? found?.count ?? null;
// What says another page is there: a page that came back full, or the
// server's own word for it. `assets.total` is not the size of the library —
// in Immich v2 it is the size of the page it came with — and read as the
// library it made every page but one look like there was more behind it.
const hasMore: boolean =
rawItems.length === size ||
(typeof found?.nextCursor === 'string' && !!found.nextCursor) ||
(total !== null && page * size < total);
(typeof found?.assets?.nextPage === 'string' && !!found.assets.nextPage) ||
(typeof found?.nextPage === 'string' && !!found.nextPage);
return reply.send({
ok: true,
page,
@@ -489,6 +497,20 @@ export function immichRoutes(
});
});
// The timeline buckets (years/months), so the client knows all available years and photo counts in 1 call.
app.get('/api/immich/buckets', async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const q = req.query as Record<string, string | undefined>;
const server = pick(user, reply, q.server);
if (!server) return;
const size = q.size === 'YEAR' ? 'YEAR' : 'MONTH';
const res = await call(server, { path: '/timeline/buckets', query: { size }, timeout: 15_000 });
if (!res.ok) return reply.status(502).send(refusal(res));
const buckets = await jsonOf<any>(res);
return reply.send({ ok: true, buckets: Array.isArray(buckets) ? buckets : [] });
});
// An Immich-generated image, streamed straight through. Always the size the
// caller asked for: the grid asks for `thumbnail`, the stage for `preview`,
// and only the studio ever asks for the original.
@@ -528,4 +550,95 @@ export function immichRoutes(
reply.header('cache-control', 'no-store');
return reply.send(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]));
});
// Upload an edited photo from Studio to Immich Cloud
app.post(
'/api/immich/upload',
{ bodyLimit: 50 * 1024 * 1024 },
async (req, reply) => {
const user = member(req, reply);
if (!user) return;
const q = req.query as Record<string, string | undefined>;
const server = pick(user, reply, q.server);
if (!server) return;
if (server.type !== 'api') {
return reply.status(400).send({ error: 'Upload is only supported for API-key Immich connections' });
}
const body = req.body;
if (!Buffer.isBuffer(body) || body.length === 0) {
return reply.status(400).send({ error: 'invalid body or empty photo' });
}
const rawName = (q.name ?? '').trim() || `recipescam_${Date.now()}.jpg`;
const fileName = rawName.endsWith('.jpg') || rawName.endsWith('.jpeg') || rawName.endsWith('.png') ? rawName : `${rawName}.jpg`;
const form = new FormData();
form.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
form.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
form.append('deviceId', 'RecipesCam');
form.append('fileCreatedAt', new Date().toISOString());
form.append('fileModifiedAt', new Date().toISOString());
form.append('isFavorite', 'false');
try {
let immichRes = await fetch(new URL(`${server.url}/api/assets`), {
method: 'POST',
headers: {
'x-api-key': server.key,
},
body: form,
signal: AbortSignal.timeout(60_000),
});
if (immichRes.status === 404) {
// Fallback for older Immich versions (< 1.106)
const fallbackForm = new FormData();
fallbackForm.append('assetData', new Blob([body], { type: 'image/jpeg' }), fileName);
fallbackForm.append('deviceAssetId', `rc_${Date.now()}_${randomBytes(4).toString('hex')}`);
fallbackForm.append('deviceId', 'RecipesCam');
fallbackForm.append('fileCreatedAt', new Date().toISOString());
fallbackForm.append('fileModifiedAt', new Date().toISOString());
fallbackForm.append('isFavorite', 'false');
immichRes = await fetch(new URL(`${server.url}/api/asset/upload`), {
method: 'POST',
headers: {
'x-api-key': server.key,
},
body: fallbackForm,
signal: AbortSignal.timeout(60_000),
});
}
if (!immichRes.ok) {
const errText = await immichRes.text().catch(() => '');
return reply.status(immichRes.status).send({ error: 'Immich upload failed', details: errText });
}
const data = await immichRes.json().catch(() => ({}));
const assetId = (data as any)?.id;
// If target album is given, add to album
const targetAlbum = q.album || (server.albums?.length === 1 ? server.albums[0] : undefined);
if (targetAlbum && assetId) {
try {
await fetch(new URL(`${server.url}/api/albums/${targetAlbum}/assets`), {
method: 'PUT',
headers: {
'x-api-key': server.key,
'content-type': 'application/json',
},
body: JSON.stringify({ ids: [assetId] }),
signal: AbortSignal.timeout(10_000),
});
} catch {}
}
return reply.send({ ok: true, id: assetId, result: data });
} catch (err: any) {
return reply.status(502).send({ error: 'Failed to connect to Immich server', details: String(err) });
}
}
);
}
+7 -3
View File
@@ -150,9 +150,13 @@ app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body,
// Photo uploads are the raw image bytes, not multipart: one file per request
// needs no boundary parsing, so no dependency and no parser attack surface.
app.addContentTypeParser(['image/jpeg', 'image/png', 'image/webp'], { parseAs: 'buffer' }, (_req, body, done) => {
done(null, body);
});
app.addContentTypeParser(
['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream'],
{ parseAs: 'buffer', bodyLimit: 50 * 1024 * 1024 },
(_req, body, done) => {
done(null, body);
}
);
// A restore body is a whole data dir, so it is the one body far past the
// instance limit. Buffered, not streamed: `tar` needs the archive to be