From a539f422c5bca868b2c664370f9d3d63e21d652c Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 13 Oct 2026 07:49:08 +0700 Subject: [PATCH] feat(immich): an upload with no album ticked lands in the app's own RecipesCam album, made on first use --- docker/backend/src/immich.ts | 27 +++++++++++++- docker/backend/test/immich.mjs | 68 +++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/docker/backend/src/immich.ts b/docker/backend/src/immich.ts index 19a0e8b..4f257fc 100644 --- a/docker/backend/src/immich.ts +++ b/docker/backend/src/immich.ts @@ -204,6 +204,25 @@ async function listAlbums(server: ImmichServer): Promise { return ((await jsonOf(res)) ?? []).filter((album) => album.id).map((album) => albumRow(album)); } +// The album this app keeps for its own uploads: made once, on the first upload +// that has no album of its own to go to, then found by name every time after. +// ponytail: found by name, not remembered as an id — an album renamed or deleted +// on Immich is followed (and remade) rather than fought; cache the id in +// `server.albums` if the extra list on every upload ever shows up in a profile. +const APP_ALBUM = 'RecipesCam'; +async function appAlbumId(server: ImmichServer): Promise { + const albums = await listAlbums(server); + const found = Array.isArray(albums) + ? albums.find((album) => album.albumName.trim().toLowerCase() === APP_ALBUM.toLowerCase()) + : undefined; + if (found?.id) return found.id; + // A key without `album.create` is refused here, and the photo keeps the + // timeline-only fate it had before: upload is not what failed. + const res = await call(server, { method: 'POST', path: '/albums', json: { albumName: APP_ALBUM } }); + if (!res.ok) return null; + return (await jsonOf(res))?.id ?? null; +} + // 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 @@ -619,8 +638,12 @@ export function immichRoutes( 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); + // Where the photo should land: the album asked for, else the single album + // this account ticked, else the one album the app keeps for itself — made + // on the spot the first time. No key that can hold albums? The photo keeps + // the timeline, as it always did. + const targetAlbum = + q.album || (server.albums?.length === 1 ? server.albums[0] : undefined) || (assetId ? await appAlbumId(server) : undefined); if (targetAlbum && assetId) { try { await fetch(new URL(`${server.url}/api/albums/${targetAlbum}/assets`), { diff --git a/docker/backend/test/immich.mjs b/docker/backend/test/immich.mjs index 7bda80f..5f66741 100644 --- a/docker/backend/test/immich.mjs +++ b/docker/backend/test/immich.mjs @@ -26,6 +26,7 @@ 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 APP_ALBUM_ID = '55555555-5555-4555-8555-555555555555'; 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')}`; @@ -62,6 +63,10 @@ const check = (name, ok, detail = '') => { // Every request is recorded, so "did the proxy attach the key, and did it ask // for the size it promised?" is an assertion rather than a hope. const seen = []; +// The albums the fake was asked to make, and the photos it was asked to file +// into one — an upload's album is an assertion here, not a hope. +const madeAlbums = []; +const addedTo = []; function startFake() { const server = createServer((req, res) => { const url = new URL(req.url, 'http://127.0.0.1'); @@ -82,7 +87,13 @@ function startFake() { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { - const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; + const raw = chunks.length ? Buffer.concat(chunks).toString() : ''; + let body = {}; + try { + body = raw ? JSON.parse(raw) : {}; + } catch { + /* an upload is multipart, which is not JSON and never needs to be */ + } const authed = apiKey === KEY || apiKey === KEY_NODL; // A share link is a key too, but only Immich's asset routes read it from // `?key=` — the album and search routes are API-key territory. @@ -100,14 +111,39 @@ function startFake() { }); } + if (url.pathname === '/api/albums' && req.method === 'POST') { + if (!authed) return send(401, { message: 'Authentication required' }); + const made = { id: APP_ALBUM_ID, albumName: body.albumName, assetCount: 0, albumThumbnailAssetId: null, shared: false }; + madeAlbums.push(made); + return send(201, made); + } + if (url.pathname === '/api/albums') { if (!authed) return send(401, { message: 'Authentication required' }); return send(200, [ { id: ALBUM_A, albumName: 'Nhà', assetCount: 2, albumThumbnailAssetId: assetId(1), shared: false }, { id: ALBUM_B, albumName: 'Du lịch', assetCount: 1, albumThumbnailAssetId: assetId(3), shared: true }, + ...madeAlbums, ]); } + const fileInto = url.pathname.match(/^\/api\/albums\/([^/]+)\/assets$/); + if (fileInto && req.method === 'PUT') { + if (!authed) return send(401, { message: 'Authentication required' }); + addedTo.push({ album: fileInto[1], ids: body.ids }); + return send(200, {}); + } + + // Both upload routes Immich has offered over the years, the older name + // included: the proxy falls back to `/api/asset/upload` on a 404. + if (req.method === 'POST' && (url.pathname === '/api/assets' || url.pathname === '/api/asset/upload')) { + if (!authed) return send(401, { message: 'Authentication required' }); + if (!String(req.headers['content-type'] ?? '').includes('multipart/form-data')) { + return send(400, { message: 'Validation failed' }); + } + return send(201, { id: assetId(9), status: 'created' }); + } + // 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. @@ -390,6 +426,36 @@ try { const unknownDelete = await user.req('/immich/config?id=deadbeef', { method: 'DELETE' }); check('forgetting an unknown server is a 404', unknownDelete.status === 404, `got ${unknownDelete.status}`); + // ---- where an upload lands ---------------------------------------------- + // With no album ticked, the app keeps one album of its own: made on the first + // upload, found by name on every one after. + const put = (name) => + user.req(`/immich/upload?server=${serverId}&name=${name}`, { + method: 'POST', + headers: { 'content-type': 'image/jpeg' }, + body: Buffer.from(`jpeg-${name}`), + }); + const first = await put('one.jpg'); + check('an upload answers with the new asset id', first.status === 200 && first.body?.id === assetId(9), JSON.stringify(first.body)); + check('no album ticked makes the app its own album', madeAlbums.length === 1 && madeAlbums[0].albumName === 'RecipesCam', JSON.stringify(madeAlbums)); + check('and the photo is filed into it', addedTo.at(-1)?.album === APP_ALBUM_ID && addedTo.at(-1)?.ids?.[0] === assetId(9), JSON.stringify(addedTo.at(-1))); + await put('two.jpg'); + check('the next upload reuses it instead of making another', madeAlbums.length === 1 && addedTo.length === 2, JSON.stringify(madeAlbums.length)); + + const missing = await user.req(`/immich/upload?server=${serverId}&name=empty.jpg`, { + method: 'POST', + headers: { 'content-type': 'image/jpeg' }, + body: Buffer.alloc(0), + }); + check('an empty upload is refused', missing.status === 400, `got ${missing.status}`); + + // A ticked album is the user's own choice, and the app stays out of it. + await user.put('/immich/albums', { id: serverId, selected: [ALBUM_A] }); + await put('three.jpg'); + check('a ticked album is where the photo goes', addedTo.at(-1)?.album === ALBUM_A, JSON.stringify(addedTo.at(-1))); + check('and no app album is made for it', madeAlbums.length === 1, String(madeAlbums.length)); + await user.put('/immich/albums', { id: serverId, selected: [] }); + // ---- no key ever reaches the browser ------------------------------------ const everything = JSON.stringify([ config.body,