feat(immich): an upload with no album ticked lands in the app's own RecipesCam album, made on first use

This commit is contained in:
2026-10-13 07:49:08 +07:00
parent 0e4e5218cf
commit a539f422c5
2 changed files with 92 additions and 3 deletions
+67 -1
View File
@@ -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,