fix(journey): select all takes every photo in the range and imports keep their order

The photo picker's Select all took only the pages already loaded, 50 at a
time, and replaced whatever had been picked on another tab. It now loads
the rest of a day, trip or date-range search before selecting (cancelled
by closing the picker or switching tab), adds to earlier picks, and
Deselect all only clears what is in view. Add stays disabled until that
loading is done. The whole-library tab keeps selecting what is loaded and
shows the count as "N+".

A date-filtered Immich search stopped at 1,000 photos without a word,
because its page budget was absolute; it is now relative to the page
asked for, with an absolute ceiling of 270 raw pages sized so the
picker's deepest page still gets a full answer. Adds go out in batches of
500 ids, under the JSON body limit, and each batch is sent oldest first,
so photos land in an entry in the order they were taken. When a later
batch fails, the photos already saved are counted and the journey
reloads while the error still shows, through one hook for desktop and
phone.

After a provider add, the capture-time lookup runs at most four at a time
per user, shared by all of that user's batches. When it fills a row, a
journey:photos:updated event reloads open journeys once per batch, from
the REST routes and the MCP add tool alike. Device uploads, which arrive
one file per request, get the same lookup without the event.

Addresses discussion #1587.
This commit is contained in:
Maurice
2026-09-24 23:33:11 +00:00
parent 650451bdf7
commit c7c9ba24a3
50 changed files with 2440 additions and 246 deletions
+36 -1
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-058
// FE-APISURF-001 to FE-APISURF-059
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -645,6 +645,41 @@ describe('client > request payloads', () => {
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap', passphrase: 'secret', media_types: ['image', 'video'] })
})
it('FE-APISURF-059: a provider-photo add past the batch size goes out in batches and answers as one (#1587)', async () => {
const bodies: Array<{ url: string; body: Record<string, unknown> }> = []
server.use(http.post(/\/provider-photos$/, async ({ request }) => {
const body = await request.json() as { asset_ids: string[] }
bodies.push({ url: new URL(request.url).pathname, body })
return HttpResponse.json({ photos: body.asset_ids.map(id => ({ asset_id: id })), added: body.asset_ids.length })
}))
const ids = Array.from({ length: 1001 }, (_, i) => `a${i}`)
const types = ids.map((_, i) => (i % 3 === 0 ? 'video' : 'image'))
const entry = await journeyApi.addProviderPhotos(9, 'immich', ids, 'cap', 'pw', types)
// One request of 2,180 Immich ids already hit the 100 kB body limit.
expect(bodies.map(b => (b.body.asset_ids as string[]).length)).toEqual([500, 500, 1])
for (const { url, body } of bodies) {
expect(url).toBe('/api/journeys/entries/9/provider-photos')
expect(body).toMatchObject({ provider: 'immich', caption: 'cap', passphrase: 'pw' })
// Each id keeps its own media type across the cut.
const sent = body.asset_ids as string[]
expect(body.media_types).toEqual(sent.map(id => types[Number(id.slice(1))]))
}
expect(bodies.flatMap(b => b.body.asset_ids as string[])).toEqual(ids)
expect(entry.added).toBe(1001)
expect(entry.photos).toHaveLength(1001)
bodies.length = 0
const gallery = await journeyApi.addProviderPhotosToGallery(2, 'immich', ids.slice(0, 501))
expect(bodies.map(b => b.url)).toEqual(['/api/journeys/2/gallery/provider-photos', '/api/journeys/2/gallery/provider-photos'])
expect(bodies.map(b => b.body)).toEqual([
{ provider: 'immich', asset_ids: ids.slice(0, 500) },
{ provider: 'immich', asset_ids: ids.slice(500, 501) },
])
expect(gallery.added).toBe(501)
})
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })