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
+25 -2
View File
@@ -204,6 +204,25 @@ async function listAlbums(server: ImmichServer): Promise<AlbumRow[] | Failure> {
return ((await jsonOf<RawAlbum[]>(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<string | null> {
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<RawAlbum>(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`), {
+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,