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:
@@ -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`), {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user