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:
@@ -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) });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user