mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 })
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { z } from 'zod'
|
||||
import type { Day, Place, Trip } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import { postProviderPhotosInBatches } from './providerPhotoBatches'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
inAppListResultSchema, type InAppListResult,
|
||||
@@ -1018,9 +1019,13 @@ export const journeyApi = {
|
||||
/** A clip on one entry: the video plus the poster frame the browser grabbed (issue #2341). */
|
||||
uploadEntryVideo: (entryId: number, formData: FormData, opts?: UploadOptions) =>
|
||||
postMultipart(`/journeys/entries/${entryId}/video`, formData, opts),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
// Both provider-photo adds go out in batches of PROVIDER_PHOTO_BATCH ids: one
|
||||
// request for a whole trip ran into the 100 kB body limit (#1587).
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) =>
|
||||
postProviderPhotosInBatches(assetIds, mediaTypes, (ids, types) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: ids, ...(passphrase ? { passphrase } : {}), ...(types ? { media_types: types } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data)),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) =>
|
||||
postProviderPhotosInBatches(assetIds, mediaTypes, (ids, types) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: ids, caption, ...(passphrase ? { passphrase } : {}), ...(types ? { media_types: types } : {}) }).then(r => r.data)),
|
||||
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PROVIDER_PHOTO_BATCH, ProviderPhotoBatchError, postProviderPhotosInBatches } from './providerPhotoBatches'
|
||||
|
||||
describe('postProviderPhotosInBatches (#1587)', () => {
|
||||
it('FE-PPBATCH-001: cuts ids and media types at the same positions and sums the answers', async () => {
|
||||
const post = vi.fn(async (ids: string[]) => ({ photos: ids.map(id => ({ id })), added: ids.length - 1 }))
|
||||
const ids = ['a', 'b', 'c', 'd', 'e']
|
||||
const types = ['image', 'video', 'image', 'video', 'image']
|
||||
|
||||
const result = await postProviderPhotosInBatches(ids, types, post, 2)
|
||||
|
||||
expect(post.mock.calls).toEqual([
|
||||
[['a', 'b'], ['image', 'video']],
|
||||
[['c', 'd'], ['image', 'video']],
|
||||
[['e'], ['image']],
|
||||
])
|
||||
expect(result).toEqual({ photos: ids.map(id => ({ id })), added: 2 })
|
||||
})
|
||||
|
||||
it('FE-PPBATCH-002: leaves media types out of every batch when none were given', async () => {
|
||||
const post = vi.fn(async () => ({ added: 1 }))
|
||||
|
||||
await postProviderPhotosInBatches(['a', 'b', 'c'], undefined, post, 2)
|
||||
|
||||
expect(post.mock.calls).toEqual([[['a', 'b'], undefined], [['c'], undefined]])
|
||||
})
|
||||
|
||||
it('FE-PPBATCH-003: an empty selection still makes the one request it always made', async () => {
|
||||
const post = vi.fn(async () => undefined)
|
||||
|
||||
expect(await postProviderPhotosInBatches([], undefined, post)).toEqual({ photos: [], added: 0 })
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
expect(post).toHaveBeenCalledWith([], undefined)
|
||||
})
|
||||
|
||||
it('FE-PPBATCH-004: a failing batch stops the run and rejects, the batches after it are never sent', async () => {
|
||||
const post = vi.fn()
|
||||
.mockResolvedValueOnce({ photos: [], added: 2 })
|
||||
.mockRejectedValueOnce(new Error('413'))
|
||||
const ids = Array.from({ length: 5 }, (_, i) => `id${i}`)
|
||||
|
||||
await expect(postProviderPhotosInBatches(ids, undefined, post, 2)).rejects.toThrow('413')
|
||||
expect(post).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-PPBATCH-006: a later batch that fails still reports what the earlier batches saved', async () => {
|
||||
const cause = new Error('502')
|
||||
const post = vi.fn()
|
||||
.mockResolvedValueOnce({ photos: [{ id: 1 }, { id: 2 }], added: 2 })
|
||||
.mockResolvedValueOnce({ photos: [{ id: 3 }], added: 1 })
|
||||
.mockRejectedValueOnce(cause)
|
||||
const ids = Array.from({ length: 7 }, (_, i) => `id${i}`)
|
||||
|
||||
const err = await postProviderPhotosInBatches(ids, undefined, post, 2).catch((e: unknown) => e)
|
||||
|
||||
expect(err).toBeInstanceOf(ProviderPhotoBatchError)
|
||||
expect(err).toMatchObject({ added: 3, photos: [{ id: 1 }, { id: 2 }, { id: 3 }], message: '502', cause })
|
||||
expect(post).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('FE-PPBATCH-007: a first batch that fails rejects with the request error itself, nothing having been saved', async () => {
|
||||
const cause = { response: { status: 413 } }
|
||||
const post = vi.fn().mockRejectedValueOnce(cause)
|
||||
|
||||
await expect(postProviderPhotosInBatches(['a', 'b', 'c'], undefined, post, 2)).rejects.toBe(cause)
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-PPBATCH-008: a cause that is not an Error still gets a message', () => {
|
||||
const err = new ProviderPhotoBatchError({ photos: [], added: 0 }, 'gone')
|
||||
|
||||
expect(err.message).toBe('Adding the photos failed part way')
|
||||
expect(err.name).toBe('ProviderPhotoBatchError')
|
||||
expect(err.cause).toBe('gone')
|
||||
})
|
||||
|
||||
it('FE-PPBATCH-005: batches of 500 by default, far under the 100 kB body limit', async () => {
|
||||
const post = vi.fn(async (ids: string[], _types?: string[]) => ({ added: ids.length }))
|
||||
const ids = Array.from({ length: 1200 }, (_, i) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`)
|
||||
|
||||
const result = await postProviderPhotosInBatches(ids, ids.map(() => 'image'), post)
|
||||
|
||||
expect(PROVIDER_PHOTO_BATCH).toBe(500)
|
||||
expect(post.mock.calls.map(([batch]) => batch.length)).toEqual([500, 500, 200])
|
||||
expect(result.added).toBe(1200)
|
||||
const largest = JSON.stringify({ provider: 'immich', asset_ids: post.mock.calls[0][0], media_types: post.mock.calls[0][1] })
|
||||
expect(largest.length).toBeLessThan(100 * 1024)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Most provider asset ids one add request carries (#1587).
|
||||
*
|
||||
* The server parses JSON bodies up to 100 kB, and an Immich id with its media
|
||||
* type costs about 47 bytes, so a single request stopped at roughly 2,180
|
||||
* photos with a 413 the moment "Select all" could reach a whole trip. 500 keeps
|
||||
* every request far under that for either provider.
|
||||
*/
|
||||
export const PROVIDER_PHOTO_BATCH = 500
|
||||
|
||||
/** What the two provider-photo add routes answer, summed over every batch. */
|
||||
export interface ProviderPhotosAdded {
|
||||
photos: unknown[]
|
||||
added: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A batched add that failed after earlier batches were stored.
|
||||
*
|
||||
* Those batches are on the journey whatever happens next, so the error carries
|
||||
* what they added: a caller can count them and reload, and still report the
|
||||
* failure for the rest. `cause` is what the failing batch rejected with.
|
||||
*/
|
||||
export class ProviderPhotoBatchError extends Error implements ProviderPhotosAdded {
|
||||
readonly photos: unknown[]
|
||||
readonly added: number
|
||||
readonly cause: unknown
|
||||
|
||||
constructor(saved: ProviderPhotosAdded, cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : 'Adding the photos failed part way')
|
||||
this.name = 'ProviderPhotoBatchError'
|
||||
this.photos = saved.photos
|
||||
this.added = saved.added
|
||||
this.cause = cause
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many photos a failed add stored all the same: the count a
|
||||
* ProviderPhotoBatchError carries, 0 for any other failure.
|
||||
*/
|
||||
export function addedBeforeFailure(err: unknown): number {
|
||||
return err instanceof ProviderPhotoBatchError ? err.added : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a provider-photo add in batches the body limit lets through, and answer
|
||||
* as one request would have.
|
||||
*
|
||||
* `mediaTypes` is parallel to `assetIds` and is cut at the same positions, so
|
||||
* every id keeps its own type. The batches go one after the other: the server
|
||||
* numbers photos in the order it receives them, and the picker sends them in
|
||||
* capture order, which concurrent requests could interleave. A failing batch
|
||||
* stops the run. When it is the first, the request's own error comes through
|
||||
* unchanged; after that it is a ProviderPhotoBatchError carrying what the
|
||||
* earlier batches stored. Adding those again is harmless because the server
|
||||
* skips what is already there.
|
||||
*
|
||||
* An empty list still makes the one request it always made.
|
||||
*/
|
||||
export async function postProviderPhotosInBatches(
|
||||
assetIds: string[],
|
||||
mediaTypes: string[] | undefined,
|
||||
post: (assetIds: string[], mediaTypes: string[] | undefined) => Promise<Partial<ProviderPhotosAdded> | undefined>,
|
||||
batchSize: number = PROVIDER_PHOTO_BATCH,
|
||||
): Promise<ProviderPhotosAdded> {
|
||||
const photos: unknown[] = []
|
||||
let added = 0
|
||||
let start = 0
|
||||
do {
|
||||
const end = start + batchSize
|
||||
let result: Partial<ProviderPhotosAdded> | undefined
|
||||
try {
|
||||
result = await post(assetIds.slice(start, end), mediaTypes?.slice(start, end))
|
||||
} catch (err) {
|
||||
if (start === 0) throw err
|
||||
throw new ProviderPhotoBatchError({ photos, added }, err)
|
||||
}
|
||||
photos.push(...(result?.photos ?? []))
|
||||
added += result?.added ?? 0
|
||||
start = end
|
||||
} while (start < assetIds.length)
|
||||
return { photos, added }
|
||||
}
|
||||
@@ -73,6 +73,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'journey:entry:deleted',
|
||||
'journey:entries:reordered',
|
||||
'journey:contributor:changed',
|
||||
// Capture times landed after a photo import (#1587); the same listener reloads
|
||||
// the journey so the gallery re-sorts.
|
||||
'journey:photos:updated',
|
||||
// Studio book — components/Studio/useBookStore (its own listener: a client
|
||||
// with nothing outstanding takes the new version, one with unsaved edits
|
||||
// deliberately does not and conflicts on its next save instead)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-019
|
||||
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-020
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act, useRef, useState } from 'react'
|
||||
@@ -9,6 +9,9 @@ import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyTrip } from '../../store/journeyStore'
|
||||
import type { UploadProgress } from '../../utils/uploadQueue'
|
||||
import { GalleryView } from './JourneyDetailPageGalleryView'
|
||||
import { useProviderPhotoAdds } from '../../pages/journeyDetail/useProviderPhotoAdds'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { ProviderPhotoBatchError } from '../../api/providerPhotoBatches'
|
||||
|
||||
type ToastKind = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
@@ -74,7 +77,8 @@ const entries: JourneyEntry[] = [
|
||||
]
|
||||
|
||||
/** Mirrors the page wiring: the gallery reports its connected providers up,
|
||||
and the host renders one button per provider next to Upload. */
|
||||
and the host renders one button per provider next to Upload. The picker's
|
||||
Add goes through the same hook the page takes from useJourneyDetail. */
|
||||
function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }: {
|
||||
gallery: GalleryPhoto[]
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
@@ -83,6 +87,7 @@ function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }:
|
||||
}) {
|
||||
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const browseRef = useRef<((provider: string) => void) | null>(null)
|
||||
const { addPickedPhotos } = useProviderPhotoAdds(onRefresh)
|
||||
return (
|
||||
<>
|
||||
{providers.map(p => (
|
||||
@@ -96,6 +101,7 @@ function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }:
|
||||
trips={trips}
|
||||
onPhotoClick={onPhotoClick}
|
||||
onRefresh={onRefresh}
|
||||
onAddProviderPhotos={addPickedPhotos}
|
||||
onRegisterUpload={onRegisterUpload}
|
||||
onRegisterProviders={(p, browse) => { setProviders(p); browseRef.current = browse }}
|
||||
/>
|
||||
@@ -309,6 +315,26 @@ describe('GalleryView', () => {
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-GALLERY-020: an add that fails after earlier batches landed counts those, refreshes, and still reports the failure (#1587)', async () => {
|
||||
useConnectedImmich()
|
||||
const add = vi.spyOn(journeyApi, 'addProviderPhotosToGallery')
|
||||
.mockRejectedValueOnce(new ProviderPhotoBatchError({ photos: [], added: 500 }, new Error('502')))
|
||||
const user = userEvent.setup()
|
||||
const { onRefresh } = mountGallery([])
|
||||
|
||||
try {
|
||||
await user.click(await screen.findByRole('button', { name: 'Immich' }))
|
||||
await user.click(await screen.findByAltText(''))
|
||||
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
|
||||
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1))
|
||||
expect(toastSpy).toHaveBeenCalledWith('500 photos added', 'success', undefined)
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined)
|
||||
} finally {
|
||||
add.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-JRN-GALLERY-014: uploads picked files and refreshes on success', async () => {
|
||||
let registered: (() => void) | null = null
|
||||
const { container, onRefresh } = mountGallery([], fn => { registered = fn })
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import { photoUrl, posterlessVideo } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload, onRegisterProviders }: {
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onAddProviderPhotos, onRegisterUpload, onRegisterProviders }: {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
journeyId: number
|
||||
@@ -21,6 +21,8 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
trips: JourneyTrip[]
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
onRefresh: () => void
|
||||
/** What the picker's Add does: the host's useProviderPhotoAdds, shared with the phone screen. */
|
||||
onAddProviderPhotos: (journeyId: number, provider: string, groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
|
||||
onRegisterUpload?: (fn: () => void) => void
|
||||
onRegisterProviders?: (providers: { id: string; name: string }[], browse: (provider: string) => void) => void
|
||||
}) {
|
||||
@@ -208,27 +210,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onAdd={async (groups, entryId) => {
|
||||
let added = 0
|
||||
let anyFailed = false
|
||||
for (const group of groups) {
|
||||
try {
|
||||
if (entryId) {
|
||||
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase, group.mediaTypes)
|
||||
added += result.added || 0
|
||||
} else {
|
||||
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase, group.mediaTypes)
|
||||
added += result.added || 0
|
||||
}
|
||||
} catch {
|
||||
anyFailed = true
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
onRefresh()
|
||||
} else if (anyFailed) {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
await onAddProviderPhotos(journeyId, pickerProvider!, groups, entryId)
|
||||
setShowPicker(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-023
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-036
|
||||
|
||||
import { describe, it, expect, afterAll, beforeAll, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -6,6 +6,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { PROVIDER_SEARCH_LAST_PAGE } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
|
||||
const trips: JourneyTrip[] = [
|
||||
@@ -115,7 +116,7 @@ describe('ProviderPicker', () => {
|
||||
await waitFor(() => expect(ranges).toHaveLength(2))
|
||||
// utc_offset_minutes rides along on every search: the server cannot tell
|
||||
// which 24 hours a date-only bound means without it (#2336).
|
||||
expect(ranges[1]).toEqual({ from: '', to: '', page: 1, size: 50, utc_offset_minutes: 0 })
|
||||
expect(ranges[1]).toEqual({ from: '', to: '', page: 1, size: 200, utc_offset_minutes: 0 })
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-006: the album tab loads albums and reports when there are none', async () => {
|
||||
@@ -418,6 +419,339 @@ describe('ProviderPicker', () => {
|
||||
await waitFor(() => expect(order()).toEqual(['rome', 'helsinki']))
|
||||
})
|
||||
|
||||
describe('select all across pages (#1587)', () => {
|
||||
const SEARCH = '/api/integrations/memories/immich/search'
|
||||
/** The tile button of one asset, found by the thumbnail it shows. */
|
||||
const tile = (container: HTMLElement, id: string) =>
|
||||
container.querySelector(`img[src*="/assets/0/${id}/"]`)!.closest('button') as HTMLButtonElement
|
||||
|
||||
function pagedTrip(pages: Record<number, Record<string, unknown>[]>, bodies: Record<string, unknown>[] = []) {
|
||||
const last = Math.max(...Object.keys(pages).map(Number))
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
bodies.push(body)
|
||||
return HttpResponse.json({ assets: pages[body.page] ?? [], hasMore: body.page < last })
|
||||
}))
|
||||
return bodies
|
||||
}
|
||||
|
||||
it('FE-JRN-PICKER-024: on a trip, loads the pages still missing with the same search, then selects every photo', async () => {
|
||||
const bodies = pagedTrip({ 1: [asset('p1a'), asset('p1b')], 2: [asset('p2a')], 3: [asset('p3a')] })
|
||||
const user = userEvent.setup()
|
||||
mountPicker()
|
||||
|
||||
// Pages are still pending, so the count says it is a floor.
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(2\+\)/ }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Add (4)' })).toBeEnabled()
|
||||
expect(bodies.map((b) => b.page)).toEqual([1, 2, 3])
|
||||
expect(bodies[2]).toEqual({ from: '2026-03-14', to: '2026-03-20', page: 3, size: 200, utc_offset_minutes: 0 })
|
||||
// Everything is in now: the count is exact and the next press takes them back.
|
||||
expect(screen.getByRole('button', { name: /Deselect all \(4\)/ })).toBeInTheDocument()
|
||||
expect(await screen.findAllByAltText('')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-025: the button is busy while the pages load', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
if (body.page === 2) await gate
|
||||
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 2 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
mountPicker()
|
||||
|
||||
const button = await screen.findByRole('button', { name: /Select all \(1\+\)/ })
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => expect(button).toHaveAttribute('aria-busy', 'true'))
|
||||
expect(button).toBeDisabled()
|
||||
release()
|
||||
await waitFor(() => expect(button).toHaveAttribute('aria-busy', 'false'))
|
||||
expect(button).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Add (2)' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-026: on All Photos, selects what is loaded and never drains the library', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { from: string }
|
||||
bodies.push(body)
|
||||
return body.from
|
||||
? HttpResponse.json({ assets: [asset('t1')], hasMore: false })
|
||||
: HttpResponse.json({ assets: [asset('lib1'), asset('lib2')], hasMore: true })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
mountPicker()
|
||||
await screen.findByRole('button', { name: /Select all \(1\)/ })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /All Photos/ }))
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(2\+\)/ }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add (2)' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: /Deselect all \(2\+\)/ })).toBeInTheDocument()
|
||||
// The trip search and the first library page, nothing after them.
|
||||
expect(bodies).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-027: adds to picks from another tab, and deselect all takes back only the photos in view', async () => {
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { from: string }
|
||||
return body.from
|
||||
? HttpResponse.json({ assets: [asset('t1'), asset('t2')], hasMore: false })
|
||||
: HttpResponse.json({ assets: [asset('lib1')], hasMore: false })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
const { container, onAdd } = mountPicker()
|
||||
await screen.findByRole('button', { name: /Select all \(2\)/ })
|
||||
|
||||
await user.click(tile(container, 't1'))
|
||||
await user.click(screen.getByRole('button', { name: /All Photos/ }))
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(1\)/ }))
|
||||
expect(screen.getByRole('button', { name: 'Add (2)' })).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Deselect all \(1\)/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
|
||||
expect(onAdd).toHaveBeenCalledWith([{ assetIds: ['t1'], mediaTypes: ['image'], passphrase: undefined }], null)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-028: switching tab mid-run cancels it and selects nothing', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const pages: number[] = []
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
pages.push(body.page)
|
||||
if (body.page === 2) await gate
|
||||
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 3 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
mountPicker()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(1\+\)/ }))
|
||||
await waitFor(() => expect(pages).toEqual([1, 2]))
|
||||
await user.click(screen.getByRole('button', { name: 'Date Range' }))
|
||||
release()
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
|
||||
expect(pages).toEqual([1, 2])
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /Select all \(1\+\)/ })).toHaveAttribute('aria-busy', 'false')
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-029: closing the picker mid-run stops it', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const pages: number[] = []
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
pages.push(body.page)
|
||||
if (body.page === 2) await gate
|
||||
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 3 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = mountPicker()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(1\+\)/ }))
|
||||
await waitFor(() => expect(pages).toEqual([1, 2]))
|
||||
unmount()
|
||||
release()
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
|
||||
expect(pages).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-030: a page that fails keeps what arrived selected, and pressing again carries on', async () => {
|
||||
let failPage2 = true
|
||||
const pages: number[] = []
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
pages.push(body.page)
|
||||
if (body.page === 2 && failPage2) return new HttpResponse(null, { status: 502 })
|
||||
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 2 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
mountPicker()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(1\+\)/ }))
|
||||
expect(await screen.findByRole('button', { name: 'Add (1)' })).toBeEnabled()
|
||||
// Still pending, so still a floor, and still a select rather than a deselect.
|
||||
const again = await screen.findByRole('button', { name: /Select all \(1\+\)/ })
|
||||
|
||||
failPage2 = false
|
||||
await user.click(again)
|
||||
expect(await screen.findByRole('button', { name: 'Add (2)' })).toBeEnabled()
|
||||
expect(pages).toEqual([1, 2, 2])
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-033: Add waits for a Select all that is still loading, so it never sends only the earlier picks', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
if (body.page === 2) await gate
|
||||
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 2 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
const { container, onAdd } = mountPicker()
|
||||
await screen.findByRole('button', { name: /Select all \(1\+\)/ })
|
||||
|
||||
// A pick from before the run makes Add live on its own.
|
||||
await user.click(tile(container, 'p1'))
|
||||
const add = screen.getByRole('button', { name: 'Add (1)' })
|
||||
expect(add).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Select all \(1\+\)/ }))
|
||||
await waitFor(() => expect(add).toBeDisabled())
|
||||
fireEvent.click(add)
|
||||
expect(onAdd).not.toHaveBeenCalled()
|
||||
|
||||
release()
|
||||
const full = await screen.findByRole('button', { name: 'Add (2)' })
|
||||
await waitFor(() => expect(full).toBeEnabled())
|
||||
await user.click(full)
|
||||
expect(onAdd).toHaveBeenCalledTimes(1)
|
||||
expect(onAdd).toHaveBeenCalledWith([{ assetIds: ['p1', 'p2'], mediaTypes: ['image', 'image'], passphrase: undefined }], null)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-034: a Select all cancelled by a tab switch gives Add back, with only the earlier picks', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const pages: number[] = []
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
pages.push(body.page)
|
||||
if (body.page === 2) await gate
|
||||
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 2 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
const { container, onAdd } = mountPicker()
|
||||
await screen.findByRole('button', { name: /Select all \(1\+\)/ })
|
||||
await user.click(tile(container, 'p1'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Select all \(1\+\)/ }))
|
||||
await waitFor(() => expect(pages).toEqual([1, 2]))
|
||||
expect(screen.getByRole('button', { name: 'Add (1)' })).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Date Range' }))
|
||||
const add = screen.getByRole('button', { name: 'Add (1)' })
|
||||
await waitFor(() => expect(add).toBeEnabled())
|
||||
release()
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
|
||||
// The page that arrived after the cancel selected nothing.
|
||||
expect(screen.getByRole('button', { name: 'Add (1)' })).toBeEnabled()
|
||||
await user.click(add)
|
||||
expect(onAdd).toHaveBeenCalledWith([{ assetIds: ['p1'], mediaTypes: ['image'], passphrase: undefined }], null)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-035: offers Select all while every photo in view is already added but more pages are pending', async () => {
|
||||
// Say the trip's last day came in earlier from the Day tab: the newest page
|
||||
// is all greyed out, and the older days are still to load.
|
||||
const bodies = pagedTrip({ 1: [asset('old1'), asset('old2')], 2: [asset('new1')] })
|
||||
const user = userEvent.setup()
|
||||
const { onAdd } = mountPicker({ existingAssetIds: new Set(['old1', 'old2']) })
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(0\+\)/ }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Add (1)' })).toBeEnabled()
|
||||
expect(bodies.map((b) => b.page)).toEqual([1, 2])
|
||||
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
|
||||
expect(onAdd).toHaveBeenCalledWith([{ assetIds: ['new1'], mediaTypes: ['image'], passphrase: undefined }], null)
|
||||
})
|
||||
|
||||
/** A trip that never runs out: every page says there is more, only the first and the last hold a photo. */
|
||||
function endlessTrip(pages: number[]) {
|
||||
server.use(http.post(SEARCH, async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
pages.push(body.page)
|
||||
const photos = body.page === 1 || body.page === PROVIDER_SEARCH_LAST_PAGE ? [asset(`p${body.page}`)] : []
|
||||
return HttpResponse.json({ assets: photos, hasMore: true })
|
||||
}))
|
||||
}
|
||||
|
||||
it('FE-JRN-PICKER-036: Select all stops at the last page and keeps the "+", and nothing past it is asked for, by a second press or by scrolling', async () => {
|
||||
const pages: number[] = []
|
||||
endlessTrip(pages)
|
||||
const user = userEvent.setup()
|
||||
const { container } = mountPicker()
|
||||
/** The scroll trigger at the foot of the grid, the spinner that loads the next page. */
|
||||
const scrollTrigger = () => container.querySelector('.mt-2.py-4 .animate-spin')
|
||||
const selectAll = await screen.findByRole('button', { name: /Select all \(1\+\)/ })
|
||||
expect(scrollTrigger()).toBeInTheDocument()
|
||||
|
||||
await user.click(selectAll)
|
||||
|
||||
// The server's scan ceiling is sized for this page, so it is still
|
||||
// answered, and nothing past it is asked for.
|
||||
expect(await screen.findByRole('button', { name: 'Add (2)' }, { timeout: 10000 })).toBeEnabled()
|
||||
expect(pages).toHaveLength(PROVIDER_SEARCH_LAST_PAGE)
|
||||
expect(Math.max(...pages)).toBe(PROVIDER_SEARCH_LAST_PAGE)
|
||||
expect(scrollTrigger()).not.toBeInTheDocument()
|
||||
// Everything loaded is picked and there is more: a deselect, with the "+",
|
||||
// and selecting again takes what is loaded without asking any further.
|
||||
await user.click(screen.getByRole('button', { name: /Deselect all \(2\+\)/ }))
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: /Select all \(2\+\)/ }))
|
||||
expect(screen.getByRole('button', { name: 'Add (2)' })).toBeEnabled()
|
||||
expect(pages).toHaveLength(PROVIDER_SEARCH_LAST_PAGE)
|
||||
}, 20000)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-031: sends each group oldest first, by wall clock where the provider has one (#1587)', async () => {
|
||||
searchReturns([
|
||||
asset('late', { takenAt: '2026-03-15T12:00:00.000Z' }),
|
||||
asset('early', { takenAt: '2026-03-15T08:00:00.000Z', mediaType: 'video' }),
|
||||
// Later as an instant, earliest on the photographer's own clock.
|
||||
asset('wallclock', { takenAt: '2026-03-15T20:00:00.000Z', localTakenAt: '2026-03-15T06:00:00.000Z' }),
|
||||
asset('undated', { takenAt: null }),
|
||||
])
|
||||
const user = userEvent.setup()
|
||||
const { onAdd } = mountPicker()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(4\)/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'Add (4)' }))
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith(
|
||||
[{
|
||||
assetIds: ['wallclock', 'early', 'late', 'undated'],
|
||||
mediaTypes: ['image', 'video', 'image', 'image'],
|
||||
passphrase: undefined,
|
||||
}],
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-032: a search after an album pages again and carries no album passphrase', async () => {
|
||||
const pages: number[] = []
|
||||
server.use(
|
||||
http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({
|
||||
albums: [{ id: 'alb1', albumName: 'Rome', assetCount: 1, passphrase: 'pw' }],
|
||||
})),
|
||||
http.get('/api/integrations/memories/immich/albums/alb1/photos', () => HttpResponse.json({ assets: [asset('alb-a1')] })),
|
||||
http.post('/api/integrations/memories/immich/search', async ({ request }) => {
|
||||
const body = await request.json() as { page: number }
|
||||
pages.push(body.page)
|
||||
return HttpResponse.json({ assets: [asset(`p${body.page}`)], hasMore: body.page < 2 })
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const { onAdd } = mountPicker()
|
||||
await screen.findByRole('button', { name: /Select all \(1\+\)/ })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Albums' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Rome (1)' }))
|
||||
await screen.findByRole('button', { name: /Select all \(1\)/ })
|
||||
await user.click(screen.getByRole('button', { name: 'Trip Period' }))
|
||||
|
||||
// The trip is paged again: without the reset the album left behind hid the "+".
|
||||
await user.click(await screen.findByRole('button', { name: /Select all \(1\+\)/ }))
|
||||
expect(await screen.findByRole('button', { name: 'Add (2)' })).toBeEnabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Add (2)' }))
|
||||
expect(onAdd).toHaveBeenCalledWith([{ assetIds: ['p1', 'p2'], mediaTypes: ['image', 'image'], passphrase: undefined }], null)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-020: closes through the header button and the backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, onClose } = mountPicker()
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Calendar, Camera, Check, ChevronRight, Play, X } from 'lucide-react';
|
||||
import { Calendar, Camera, Check, ChevronRight, Loader2, Play, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memoriesApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
fetchRemainingProviderPages,
|
||||
groupPhotosByDate,
|
||||
PROVIDER_SEARCH_LAST_PAGE,
|
||||
PROVIDER_SEARCH_PAGE_SIZE,
|
||||
sortByCaptureTimeAsc,
|
||||
sortProviderPhotos,
|
||||
utcOffsetMinutesForDay,
|
||||
type GeoPoint,
|
||||
type ProviderPhotoAsset,
|
||||
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker';
|
||||
@@ -14,6 +19,22 @@ import { ScrollTrigger } from './JourneyDetailPageScrollTrigger';
|
||||
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] };
|
||||
|
||||
/** A tile as the search and album routes answer it; only what a pick needs is spelled out. */
|
||||
type PickerAsset = ProviderPhotoAsset & { mediaType?: string };
|
||||
|
||||
/**
|
||||
* One picked asset. The capture times ride along only to order the ids at
|
||||
* submit (#1587): a pick survives tab and album switches, and by then the asset
|
||||
* may no longer be in the loaded list to look them up in.
|
||||
*/
|
||||
type SelectedAsset = {
|
||||
albumId?: string;
|
||||
passphrase?: string;
|
||||
mediaType?: string;
|
||||
takenAt?: string | null;
|
||||
localTakenAt?: string | null;
|
||||
};
|
||||
|
||||
export function ProviderPicker({
|
||||
provider,
|
||||
userId,
|
||||
@@ -53,9 +74,9 @@ export function ProviderPicker({
|
||||
const [searchPage, setSearchPage] = useState(1);
|
||||
const [searchFrom, setSearchFrom] = useState('');
|
||||
const [searchTo, setSearchTo] = useState('');
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(
|
||||
new Map()
|
||||
);
|
||||
const [selected, setSelected] = useState<Map<string, SelectedAsset>>(new Map());
|
||||
// "Select all" is loading the pages that were still missing (#1587).
|
||||
const [draining, setDraining] = useState(false);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null);
|
||||
@@ -83,11 +104,37 @@ export function ProviderPicker({
|
||||
return abortRef.current.signal;
|
||||
};
|
||||
|
||||
// Closing the picker cancels whatever it is still loading, a "Select all" that
|
||||
// is working through the remaining pages included.
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
// The body of one page of a search. Shared by the page a search opens with,
|
||||
// the pages scrolled into view and the ones "Select all" loads, so every page
|
||||
// asks for the same days in the same zone (#1587).
|
||||
//
|
||||
// The bounds are calendar days as this browser reads them, so the zone they
|
||||
// are meant in travels with them. Without it the server can only take them
|
||||
// as UTC days and a reader outside UTC gets somebody else's 24 hours
|
||||
// (#2336). A single offset leaves a one-hour sliver at the far end of a
|
||||
// multi-day range that crosses a DST change, and none at all for a single
|
||||
// day, which is the case this fixes.
|
||||
const searchRequest = (from: string, to: string, page: number) => ({
|
||||
from,
|
||||
to,
|
||||
page,
|
||||
size: PROVIDER_SEARCH_PAGE_SIZE,
|
||||
utc_offset_minutes: utcOffsetMinutesForDay(from || to),
|
||||
});
|
||||
|
||||
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||
const signal = cancelPending();
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
// A search shows no album, so none may colour its picks, its thumbnails or
|
||||
// its paging. Left set, a trip search after an album never paged again.
|
||||
setSelectedAlbum(null);
|
||||
setSelectedAlbumPassphrase(undefined);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
@@ -95,17 +142,7 @@ export function ProviderPicker({
|
||||
setSearchTo(to);
|
||||
setSearchPage(page);
|
||||
try {
|
||||
// The bounds are calendar days as this browser reads them, so the zone they
|
||||
// are meant in travels with them. Without it the server can only take them
|
||||
// as UTC days and a reader outside UTC gets somebody else's 24 hours
|
||||
// (#2336). A single offset leaves a one-hour sliver at the far end of a
|
||||
// multi-day range that crosses a DST change, and none at all for a single
|
||||
// day, which is the case this fixes.
|
||||
const data = await memoriesApi.search(
|
||||
provider,
|
||||
{ from, to, page, size: 50, utc_offset_minutes: utcOffsetMinutesForDay(from || to) },
|
||||
signal,
|
||||
);
|
||||
const data = await memoriesApi.search(provider, searchRequest(from, to, page), signal);
|
||||
const assets = data.assets || [];
|
||||
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
|
||||
setHasMore(!!data.hasMore);
|
||||
@@ -120,11 +157,6 @@ export function ProviderPicker({
|
||||
}
|
||||
};
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true);
|
||||
};
|
||||
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending();
|
||||
setLoading(true);
|
||||
@@ -175,6 +207,29 @@ export function ProviderPicker({
|
||||
|
||||
// Albums come back whole, so they never page.
|
||||
const morePagesPending = hasMore && !selectedAlbum;
|
||||
const dateBounded = Boolean(searchFrom || searchTo);
|
||||
// A date-bounded search ends at PROVIDER_SEARCH_LAST_PAGE, scrolled or loaded
|
||||
// by "Select all" (#1587). Past it the server's scan ceiling could only answer
|
||||
// an empty page with no more to come, so the picker stops asking and keeps
|
||||
// what the server last said: the count keeps its "+".
|
||||
const canLoadMore = morePagesPending && !(dateBounded && searchPage >= PROVIDER_SEARCH_LAST_PAGE);
|
||||
// A date-bounded search is a trip or a day, so "Select all" loads the rest of
|
||||
// it first (#1587). The All Photos tab is the whole library: there it stays
|
||||
// "everything loaded so far", and the count says there is more.
|
||||
const selectAllLoadsRest = canLoadMore && dateBounded;
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !canLoadMore) return;
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true);
|
||||
};
|
||||
|
||||
const selectionEntry = (asset: PickerAsset | undefined): SelectedAsset => ({
|
||||
albumId: selectedAlbum ?? undefined,
|
||||
passphrase: selectedAlbumPassphrase,
|
||||
mediaType: asset?.mediaType,
|
||||
takenAt: asset?.takenAt ?? null,
|
||||
localTakenAt: asset?.localTakenAt ?? null,
|
||||
});
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
@@ -182,13 +237,78 @@ export function ProviderPicker({
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
const mediaType = (photos as any[]).find((p) => p.id === id)?.mediaType;
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType });
|
||||
next.set(id, selectionEntry(photos.find((p) => p.id === id)));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Adds to what is picked rather than replacing it: picks made on another tab
|
||||
// or in an album survive a "Select all" here.
|
||||
const addToSelection = (assets: PickerAsset[]) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const asset of assets) {
|
||||
if (existingAssetIds.has(asset.id) || next.has(asset.id)) continue;
|
||||
next.set(asset.id, selectionEntry(asset));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromSelection = (assets: PickerAsset[]) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const asset of assets) next.delete(asset.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = async (inView: PickerAsset[]) => {
|
||||
const controller = abortRef.current;
|
||||
if (!selectAllLoadsRest || !controller) {
|
||||
addToSelection(inView);
|
||||
return;
|
||||
}
|
||||
// The search's own controller: a new search, a tab switch or closing the
|
||||
// picker aborts it, and with it this run.
|
||||
const signal = controller.signal;
|
||||
const loaded: PickerAsset[] = [];
|
||||
setDraining(true);
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
await fetchRemainingProviderPages<PickerAsset>(
|
||||
(page) => memoriesApi.search(provider, searchRequest(searchFrom, searchTo, page), signal),
|
||||
searchPage + 1,
|
||||
signal,
|
||||
(assets, page, more) => {
|
||||
loaded.push(...assets);
|
||||
setPhotos((prev) => [...prev, ...assets]);
|
||||
setSearchPage(page);
|
||||
setHasMore(more);
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// A page that failed leaves the rest pending: what did arrive is still
|
||||
// selected below, the count keeps its "+", and pressing again carries on
|
||||
// from the page that failed.
|
||||
} finally {
|
||||
setDraining(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
if (!signal.aborted) addToSelection([...inView, ...loaded]);
|
||||
};
|
||||
|
||||
// Leaving a tab ends a "Select all" that is still loading its pages.
|
||||
const switchTab = (id: typeof filter) => {
|
||||
if (draining && id !== filter) cancelPending();
|
||||
setFilter(id);
|
||||
};
|
||||
|
||||
// While Select all is still loading pages, the selection holds only the picks
|
||||
// from before it: an Add then would send those and drop the rest (#1587).
|
||||
const addDisabled = selected.size === 0 || adding || draining;
|
||||
|
||||
const targetLabel = targetEntryId
|
||||
? entries.find((e) => e.id === targetEntryId)?.title ||
|
||||
entries.find((e) => e.id === targetEntryId)?.entry_date ||
|
||||
@@ -251,7 +371,7 @@ export function ProviderPicker({
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
onClick={() => switchTab(f.id)}
|
||||
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
filter === f.id
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
@@ -441,42 +561,42 @@ export function ProviderPicker({
|
||||
sortedPhotos.length > 0 &&
|
||||
(() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id));
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id));
|
||||
if (selectable.length === 0) return null;
|
||||
// Pages still to load on a trip or a day mean not everything is picked yet.
|
||||
const allSelected =
|
||||
selectable.length > 0 && !selectAllLoadsRest && selectable.every((a) => selected.has(a.id));
|
||||
// Every photo in view may already be in the journey while the rest of
|
||||
// the trip is not: the button is what loads that rest, so it stays.
|
||||
if (selectable.length === 0 && !selectAllLoadsRest) return null;
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-white px-4 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
aria-busy={draining}
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map());
|
||||
removeFromSelection(selectable);
|
||||
} else {
|
||||
setSelected(
|
||||
new Map(
|
||||
selectable.map((a: any) => [
|
||||
a.id,
|
||||
{
|
||||
albumId: selectedAlbum ?? undefined,
|
||||
passphrase: selectedAlbumPassphrase,
|
||||
mediaType: a.mediaType,
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
void selectAll(selectable);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-50 disabled:cursor-wait disabled:opacity-60 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div
|
||||
className={`flex h-3.5 w-3.5 items-center justify-center rounded border ${
|
||||
allSelected
|
||||
? 'border-zinc-900 bg-zinc-900 dark:border-white dark:bg-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
{draining ? (
|
||||
<Loader2 size={14} className="animate-spin text-content-muted" aria-hidden="true" />
|
||||
) : (
|
||||
<div
|
||||
className={`flex h-3.5 w-3.5 items-center justify-center rounded border ${
|
||||
allSelected
|
||||
? 'border-zinc-900 bg-zinc-900 dark:border-white dark:bg-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length}
|
||||
{morePagesPending ? '+' : ''})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -572,7 +692,7 @@ export function ProviderPicker({
|
||||
belong to a neighbouring day leaves the grid empty, and a sentinel
|
||||
that only exists next to photos would never ask for the next page
|
||||
(#2336). */}
|
||||
{morePagesPending && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||
{canLoadMore && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -596,24 +716,28 @@ export function ProviderPicker({
|
||||
if (adding) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>();
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] };
|
||||
g.assetIds.push(assetId);
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image');
|
||||
groupMap.set(passphrase, g);
|
||||
const groupMap = new Map<string | undefined, Array<SelectedAsset & { assetId: string }>>();
|
||||
for (const [assetId, pick] of selected.entries()) {
|
||||
const g = groupMap.get(pick.passphrase) || [];
|
||||
g.push({ ...pick, assetId });
|
||||
groupMap.set(pick.passphrase, g);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({
|
||||
assetIds: g.assetIds,
|
||||
mediaTypes: g.mediaTypes,
|
||||
passphrase,
|
||||
}));
|
||||
// Oldest first, so an entry numbers its new photos in the order
|
||||
// they were taken rather than the grid's newest-first (#1587).
|
||||
const groups = [...groupMap.entries()].map(([passphrase, picks]) => {
|
||||
const ordered = sortByCaptureTimeAsc(picks);
|
||||
return {
|
||||
assetIds: ordered.map((p) => p.assetId),
|
||||
mediaTypes: ordered.map((p) => (p.mediaType === 'video' ? 'video' : 'image')),
|
||||
passphrase,
|
||||
};
|
||||
});
|
||||
await onAdd(groups, targetEntryId);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}}
|
||||
disabled={selected.size === 0 || adding}
|
||||
disabled={addDisabled}
|
||||
className="rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{adding ? (
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function MJourneyDetail() {
|
||||
sidebarMapItems, tracks,
|
||||
dismissSuggestion, restoreSuggestions, openAtEntryId,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||
} = useJourneyDetail()
|
||||
|
||||
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
|
||||
@@ -629,9 +630,7 @@ export default function MJourneyDetail() {
|
||||
return entryId
|
||||
}}
|
||||
onUploadPhotos={uploadPhotos}
|
||||
onAddProviderPhotos={async (entryId, group) => {
|
||||
await journeyApi.addProviderPhotos(entryId, group.provider, group.assetIds, undefined, group.passphrase, group.mediaTypes)
|
||||
}}
|
||||
onAddProviderPhotos={addEntryProviderPhotos}
|
||||
onDelete={editingEntry.id > 0 && canEditEntries
|
||||
? () => { const target = editingEntry; setEditingEntry(null); setDeleteTarget(target) }
|
||||
: undefined}
|
||||
@@ -674,24 +673,7 @@ export default function MJourneyDetail() {
|
||||
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
|
||||
onClose={() => setPickerProvider(null)}
|
||||
onAdd={async (groups, entryId) => {
|
||||
let added = 0
|
||||
let anyFailed = false
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const result = entryId
|
||||
? await journeyApi.addProviderPhotos(entryId, pickerProvider, group.assetIds, undefined, group.passphrase, group.mediaTypes)
|
||||
: await journeyApi.addProviderPhotosToGallery(current.id, pickerProvider, group.assetIds, group.passphrase, group.mediaTypes)
|
||||
added += result.added || 0
|
||||
} catch {
|
||||
anyFailed = true
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
loadJourney(Number(id))
|
||||
} else if (anyFailed) {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
await addPickedProviderPhotos(current.id, pickerProvider, groups, entryId)
|
||||
setPickerProvider(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -59,6 +59,7 @@ function JourneyDetailPageDesktop() {
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||
} = useJourneyDetail()
|
||||
|
||||
if (loading || !current) {
|
||||
@@ -602,6 +603,7 @@ function JourneyDetailPageDesktop() {
|
||||
trips={current.trips}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
|
||||
onRefresh={() => loadJourney(Number(id))}
|
||||
onAddProviderPhotos={addPickedProviderPhotos}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -702,9 +704,7 @@ function JourneyDetailPageDesktop() {
|
||||
showVerdict={current.show_verdict !== 0}
|
||||
showMood={current.show_mood !== 0}
|
||||
showWeather={current.show_weather !== 0}
|
||||
onAddProviderPhotos={async (entryId, group) => {
|
||||
await journeyApi.addProviderPhotos(entryId, group.provider, group.assetIds, undefined, group.passphrase, group.mediaTypes)
|
||||
}}
|
||||
onAddProviderPhotos={addEntryProviderPhotos}
|
||||
onDone={() => {
|
||||
setEditingEntry(null)
|
||||
loadJourney(Number(id))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-DETWIRE-001 to FE-JRN-DETWIRE-028
|
||||
// FE-JRN-DETWIRE-001 to FE-JRN-DETWIRE-041
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../tests/helpers/render';
|
||||
import { journeyApi } from '../api/client';
|
||||
@@ -112,6 +112,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
loadJourney: vi.fn(), updateEntry: vi.fn(async () => {}), deleteEntry: vi.fn(async () => {}),
|
||||
reorderEntries: vi.fn(async () => {}), uploadPhotos: vi.fn(async () => ({ succeeded: [], failed: [] })),
|
||||
deletePhoto: vi.fn(async () => {}),
|
||||
addPickedProviderPhotos: vi.fn(async () => {}), addEntryProviderPhotos: vi.fn(async () => {}),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -300,6 +301,8 @@ describe('JourneyDetailPage wiring', () => {
|
||||
|
||||
(mocks.captured.gallery.onRefresh as () => void)();
|
||||
expect(hook.loadJourney).toHaveBeenCalledWith(7);
|
||||
// The picker's Add is the hook's, the one the phone screen uses too (#1587).
|
||||
expect(mocks.captured.gallery.onAddProviderPhotos).toBe(hook.addPickedProviderPhotos);
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-016: the entry editor creates a new entry and updates an existing one', async () => {
|
||||
@@ -311,11 +314,9 @@ describe('JourneyDetailPage wiring', () => {
|
||||
await (mocks.captured.editor.onUploadPhotos as (id: number, f: File[]) => Promise<unknown>)(88, []);
|
||||
expect(hook.uploadPhotos).toHaveBeenCalledWith(88, [], undefined);
|
||||
|
||||
const addProvider = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
|
||||
await (mocks.captured.editor.onAddProviderPhotos as (id: number, g: Record<string, unknown>) => Promise<void>)(
|
||||
88, { provider: 'immich', assetIds: ['a1'], passphrase: 'pw', mediaTypes: ['image'] },
|
||||
);
|
||||
expect(addProvider).toHaveBeenCalledWith(88, 'immich', ['a1'], undefined, 'pw', ['image']);
|
||||
// Shared with the phone entry sheet, so a group that fails part way is
|
||||
// handled once for both shells (#1587).
|
||||
expect(mocks.captured.editor.onAddProviderPhotos).toBe(hook.addEntryProviderPhotos);
|
||||
|
||||
(mocks.captured.editor.onDone as () => void)();
|
||||
expect(hook.setEditingEntry).toHaveBeenCalledWith(null);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared';
|
||||
import {
|
||||
createDraftJourneyEntry,
|
||||
distanceBetweenGeoPoints,
|
||||
fetchRemainingProviderPages,
|
||||
groupPhotosByDate,
|
||||
matchJourneyEntries,
|
||||
posterlessVideo,
|
||||
PROVIDER_SEARCH_LAST_PAGE,
|
||||
PROVIDER_SEARCH_PAGE_SIZE,
|
||||
sortByCaptureTimeAsc,
|
||||
sortProviderPhotos,
|
||||
utcOffsetMinutesForDay,
|
||||
} from './JourneyDetailPage.helpers';
|
||||
@@ -225,3 +230,133 @@ describe('posterlessVideo (#2341)', () => {
|
||||
expect(posterlessVideo({ media_type: 'video', provider: 'immich', thumbnail_path: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRemainingProviderPages (#1587)', () => {
|
||||
it('asks for every page from the one given until the provider says there are no more', async () => {
|
||||
const fetchPage = vi.fn(async (page: number) => ({ assets: [`p${page}`], hasMore: page < 4 }));
|
||||
const onPage = vi.fn();
|
||||
|
||||
const result = await fetchRemainingProviderPages(fetchPage, 2, new AbortController().signal, onPage);
|
||||
|
||||
expect(fetchPage.mock.calls.map(([page]) => page)).toEqual([2, 3, 4]);
|
||||
expect(onPage.mock.calls).toEqual([
|
||||
[['p2'], 2, true],
|
||||
[['p3'], 3, true],
|
||||
[['p4'], 4, false],
|
||||
]);
|
||||
expect(result).toEqual({ assets: ['p2', 'p3', 'p4'], hasMore: false });
|
||||
});
|
||||
|
||||
it('keeps going over a page the filter emptied, and treats a missing list as empty', async () => {
|
||||
const fetchPage = vi.fn(async (page: number) => (page === 1 ? { hasMore: true } : { assets: ['x'], hasMore: false }));
|
||||
|
||||
const result = await fetchRemainingProviderPages(fetchPage, 1, new AbortController().signal, () => {});
|
||||
|
||||
expect(result).toEqual({ assets: ['x'], hasMore: false });
|
||||
});
|
||||
|
||||
it('stops at the last page and says there is still more', async () => {
|
||||
const fetchPage = vi.fn(async (page: number) => ({ assets: [page], hasMore: true }));
|
||||
|
||||
const result = await fetchRemainingProviderPages(fetchPage, 5, new AbortController().signal, () => {}, 7);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||||
expect(result).toEqual({ assets: [5, 6, 7], hasMore: true });
|
||||
});
|
||||
|
||||
it('never asks past page 251 by default, wherever the run starts', async () => {
|
||||
const endless = () => vi.fn(async (page: number) => ({ assets: [page], hasMore: true }));
|
||||
|
||||
// A run from the first page takes the 250 after it, and ends at photo 50,200.
|
||||
const fromStart = endless();
|
||||
await fetchRemainingProviderPages(fromStart, 2, new AbortController().signal, () => {});
|
||||
expect(fromStart).toHaveBeenCalledTimes(PROVIDER_SELECT_ALL_MAX_PAGES);
|
||||
expect(fromStart).toHaveBeenLastCalledWith(PROVIDER_SEARCH_LAST_PAGE);
|
||||
expect(PROVIDER_SEARCH_LAST_PAGE).toBe(251);
|
||||
expect(PROVIDER_SEARCH_LAST_PAGE * PROVIDER_SEARCH_PAGE_SIZE).toBe(50200);
|
||||
|
||||
// Pages scrolled into view first do not push it deeper.
|
||||
const scrolled = endless();
|
||||
await fetchRemainingProviderPages(scrolled, 12, new AbortController().signal, () => {});
|
||||
expect(scrolled).toHaveBeenLastCalledWith(PROVIDER_SEARCH_LAST_PAGE);
|
||||
|
||||
// A second press after the first stopped there asks for nothing, and the
|
||||
// search stays open: the count keeps its "+".
|
||||
const again = endless();
|
||||
const onPage = vi.fn();
|
||||
const result = await fetchRemainingProviderPages(again, PROVIDER_SEARCH_LAST_PAGE + 1, new AbortController().signal, onPage);
|
||||
expect(again).not.toHaveBeenCalled();
|
||||
expect(onPage).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ assets: [], hasMore: true });
|
||||
});
|
||||
|
||||
it('stops quietly once aborted, before and between pages, and hands nothing on after the abort', async () => {
|
||||
const controller = new AbortController();
|
||||
const onPage = vi.fn();
|
||||
const fetchPage = vi.fn(async (page: number) => {
|
||||
if (page === 2) controller.abort();
|
||||
return { assets: [page], hasMore: true };
|
||||
});
|
||||
|
||||
const result = await fetchRemainingProviderPages(fetchPage, 1, controller.signal, onPage);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
expect(onPage).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ assets: [1], hasMore: true });
|
||||
|
||||
const never = vi.fn();
|
||||
await fetchRemainingProviderPages(never, 1, controller.signal, onPage);
|
||||
expect(never).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a failing page reject, so the caller can keep what arrived', async () => {
|
||||
const fetchPage = vi.fn(async (page: number) => {
|
||||
if (page === 2) throw new Error('502');
|
||||
return { assets: [page], hasMore: true };
|
||||
});
|
||||
const onPage = vi.fn();
|
||||
|
||||
await expect(fetchRemainingProviderPages(fetchPage, 1, new AbortController().signal, onPage)).rejects.toThrow('502');
|
||||
expect(onPage).toHaveBeenCalledWith([1], 1, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortByCaptureTimeAsc (#1587)', () => {
|
||||
it('puts the oldest first, by the photographer\'s wall clock where there is one', () => {
|
||||
const items = [
|
||||
{ id: 'late', takenAt: '2026-03-15T12:00:00.000Z' },
|
||||
{ id: 'early', takenAt: '2026-03-15T08:00:00.000Z' },
|
||||
{ id: 'wallclock', takenAt: '2026-03-15T20:00:00.000Z', localTakenAt: '2026-03-15T06:00:00.000Z' },
|
||||
];
|
||||
|
||||
expect(sortByCaptureTimeAsc(items).map((i) => i.id)).toEqual(['wallclock', 'early', 'late']);
|
||||
});
|
||||
|
||||
it('breaks a wall-clock tie on the instant, then on the order the items came in', () => {
|
||||
const items = [
|
||||
{ id: 'b', localTakenAt: '2026-03-15T10:00:00.000Z', takenAt: '2026-03-15T09:00:00.000Z' },
|
||||
{ id: 'a', localTakenAt: '2026-03-15T10:00:00.000Z', takenAt: '2026-03-15T08:00:00.000Z' },
|
||||
{ id: 'c', takenAt: '2026-03-15T11:00:00.000Z' },
|
||||
{ id: 'd', takenAt: '2026-03-15T11:00:00.000Z' },
|
||||
];
|
||||
|
||||
expect(sortByCaptureTimeAsc(items).map((i) => i.id)).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('keeps photos without a readable time at the end, in the order they came in', () => {
|
||||
const items = [
|
||||
{ id: 'none' },
|
||||
{ id: 'garbage', takenAt: 'not a date' },
|
||||
{ id: 'dated', takenAt: '2026-03-15T08:00:00.000Z' },
|
||||
{ id: 'blank', takenAt: '', localTakenAt: null },
|
||||
];
|
||||
|
||||
expect(sortByCaptureTimeAsc(items).map((i) => i.id)).toEqual(['dated', 'none', 'garbage', 'blank']);
|
||||
});
|
||||
|
||||
it('leaves the input alone', () => {
|
||||
const items = [{ id: 'b', takenAt: '2026-03-16T00:00:00Z' }, { id: 'a', takenAt: '2026-03-15T00:00:00Z' }];
|
||||
sortByCaptureTimeAsc(items);
|
||||
expect(items.map((i) => i.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared';
|
||||
import { GeoOnceError } from '../../hooks/useGeolocation';
|
||||
import type { JourneyEntry } from '../../store/journeyStore';
|
||||
import { localIsoDate } from '../../utils/localDate';
|
||||
@@ -253,6 +254,110 @@ export function sortProviderPhotos<T extends ProviderPhotoAsset>(photos: T[], lo
|
||||
.map((item) => item.photo);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many photos the picker asks a provider for per page (#1587).
|
||||
*
|
||||
* The search routes accept up to 200. Every date-filtered Immich page restarts
|
||||
* its scan at the first raw page on the server, so a smaller page makes loading
|
||||
* a whole trip cost quadratically more round trips; 200 is a quarter of what 50
|
||||
* cost, and still a grid the browser lays out without a stall.
|
||||
*/
|
||||
export const PROVIDER_SEARCH_PAGE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* The deepest page the picker asks a date-bounded search for (#1587): its first
|
||||
* page and the PROVIDER_SELECT_ALL_MAX_PAGES after it, photos 50,001 to 50,200.
|
||||
*
|
||||
* Absolute, however the page would be reached: by "Select all", by pressing it
|
||||
* again after it stopped here, or by scrolling. Every such page restarts the
|
||||
* server's Immich scan at its first raw page, and the server stops that scan at
|
||||
* a ceiling sized for exactly this page. One past it could come back empty with
|
||||
* `hasMore: false`, and the picker would drop the "+" and call a cut-off search
|
||||
* complete. Stopping here keeps the "+".
|
||||
*/
|
||||
export const PROVIDER_SEARCH_LAST_PAGE = PROVIDER_SELECT_ALL_MAX_PAGES + 1;
|
||||
|
||||
export interface ProviderPhotoPage<T> {
|
||||
assets?: T[];
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the pages of a provider search that are still missing, one after the
|
||||
* other, until the provider says there are no more (#1587).
|
||||
*
|
||||
* "Select all" used to mean the pages already scrolled into view, so a trip of
|
||||
* a thousand photos selected fifty. Sequential on purpose: whether there is a
|
||||
* next page is only known once this one has answered. `onPage` hands each page
|
||||
* over as it lands so the grid can grow while the button is busy.
|
||||
*
|
||||
* Stops quietly once `signal` is aborted, which is how closing the picker or
|
||||
* switching its tab cancels the run: the caller then discards what came back.
|
||||
*
|
||||
* `lastPage` is the deepest page asked for, whichever page the run starts at, so
|
||||
* a provider that keeps answering `hasMore` cannot hold the button busy for
|
||||
* ever. Running into it leaves `hasMore` true, so the count keeps its "+", and a
|
||||
* run that starts past it asks for nothing.
|
||||
*/
|
||||
export async function fetchRemainingProviderPages<T>(
|
||||
fetchPage: (page: number) => Promise<ProviderPhotoPage<T>>,
|
||||
firstPage: number,
|
||||
signal: AbortSignal,
|
||||
onPage: (assets: T[], page: number, hasMore: boolean) => void,
|
||||
lastPage: number = PROVIDER_SEARCH_LAST_PAGE,
|
||||
): Promise<{ assets: T[]; hasMore: boolean }> {
|
||||
const assets: T[] = [];
|
||||
let hasMore = true;
|
||||
for (let page = firstPage; hasMore && page <= lastPage; page++) {
|
||||
if (signal.aborted) break;
|
||||
const data = await fetchPage(page);
|
||||
if (signal.aborted) break;
|
||||
const pageAssets = data.assets ?? [];
|
||||
hasMore = !!data.hasMore;
|
||||
assets.push(...pageAssets);
|
||||
onPage(pageAssets, page, hasMore);
|
||||
}
|
||||
return { assets, hasMore };
|
||||
}
|
||||
|
||||
/** A capture time as milliseconds, or null when it is missing or unreadable. */
|
||||
function captureMillis(value?: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Items in the order their photos were taken, oldest first (#1587).
|
||||
*
|
||||
* An entry keeps its photos in the order they were added, and the picker used
|
||||
* to add them in grid order, newest first, so a bulk import landed backwards.
|
||||
* The key is the photographer's wall clock (`localTakenAt`) where the provider
|
||||
* has it, the capture instant otherwise. Ties and photos without either keep
|
||||
* the order they came in, the undated ones after the rest.
|
||||
*
|
||||
* Only the order of the ids sent changes. The capture time itself is still
|
||||
* fetched by the server (#1614), never taken from here.
|
||||
*/
|
||||
export function sortByCaptureTimeAsc<T extends { takenAt?: string | null; localTakenAt?: string | null }>(
|
||||
items: T[],
|
||||
): T[] {
|
||||
return items
|
||||
.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
key: captureMillis(item.localTakenAt) ?? captureMillis(item.takenAt),
|
||||
instant: captureMillis(item.takenAt),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.key === null && b.key === null) return a.index - b.index;
|
||||
if (a.key === null) return 1;
|
||||
if (b.key === null) return -1;
|
||||
return a.key - b.key || (a.instant ?? 0) - (b.instant ?? 0) || a.index - b.index;
|
||||
})
|
||||
.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries a search box's text still lets through.
|
||||
*
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-029
|
||||
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-031
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
|
||||
import { useLocation } from 'react-router';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import { act, render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import { addListener, removeListener } from '../../api/websocket';
|
||||
import { useJourneyStore } from '../../store/journeyStore';
|
||||
import type { JourneyDetail, JourneyEntry } from '../../store/journeyStore';
|
||||
@@ -188,6 +188,34 @@ describe('useJourneyDetail', () => {
|
||||
await waitFor(() => expect(latest.current?.title).toBe('Japan renamed'));
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-030: capture times landing after an import reload the journey so the gallery re-sorts (#1587)', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(latest.current).not.toBeNull());
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0] as (e: Record<string, unknown>) => void;
|
||||
|
||||
serveJourney(buildDetail({ gallery: [{ id: 3, photo_id: 30, taken_at: '2026-05-01T09:00:00Z' }] as never }));
|
||||
handler({ type: 'journey:photos:updated', journeyId: 7 });
|
||||
await waitFor(() => expect(latest.current?.gallery).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-031: provider photos added from the picker or the editor reload this journey once some landed (#1587)', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(latest.current).not.toBeNull());
|
||||
const toGallery = vi.fn(async () => ({ photos: [], added: 2 }));
|
||||
const toEntry = vi.fn(async () => ({ photos: [], added: 1 }));
|
||||
useJourneyStore.setState({ addProviderPhotosToGallery: toGallery, addProviderPhotos: toEntry } as never);
|
||||
|
||||
serveJourney(buildDetail({ title: 'After the import' }));
|
||||
await act(() => latest.addPickedProviderPhotos(7, 'immich', [{ assetIds: ['a1', 'a2'] }], null));
|
||||
expect(toGallery).toHaveBeenCalledWith(7, 'immich', { assetIds: ['a1', 'a2'] });
|
||||
expect(addToast).toHaveBeenCalledWith('2 photos added', 'success', undefined);
|
||||
await waitFor(() => expect(latest.current?.title).toBe('After the import'));
|
||||
|
||||
const group = { provider: 'immich', assetIds: ['b1'] };
|
||||
await act(() => latest.addEntryProviderPhotos(1, group));
|
||||
expect(toEntry).toHaveBeenCalledWith(1, 'immich', group);
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-009: events of another type or another journey are ignored', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(latest.current).not.toBeNull());
|
||||
|
||||
@@ -17,6 +17,7 @@ import { openStaysByDate } from '../../components/Dawarich/dawarichSuggestionMod
|
||||
import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
|
||||
import { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail'
|
||||
import { useProviderPhotoAdds } from './useProviderPhotoAdds'
|
||||
|
||||
/** Stable identity for "this journey draws no trip tracks" (#2194). */
|
||||
const NO_TRACKS: JourneyTrack[] = []
|
||||
@@ -290,6 +291,10 @@ export function useJourneyDetail() {
|
||||
}
|
||||
}, [updateEntry, loadJourney, current, toast, t])
|
||||
|
||||
// Provider photos from the gallery picker and from the entry editor, the same
|
||||
// way on desktop and phone (#1587).
|
||||
const providerPhotos = useProviderPhotoAdds(() => { void loadJourney(Number(id)) })
|
||||
|
||||
const restoreSuggestions = useCallback(async () => {
|
||||
if (!current) return
|
||||
try {
|
||||
@@ -541,5 +546,7 @@ export function useJourneyDetail() {
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
addPickedProviderPhotos: providerPhotos.addPickedPhotos,
|
||||
addEntryProviderPhotos: providerPhotos.addEntryPhotos,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// FE-JRN-PPADD-001 to FE-JRN-PPADD-007
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
|
||||
import { renderHook } from '../../../tests/helpers/render'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
import { ProviderPhotoBatchError } from '../../api/providerPhotoBatches'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useProviderPhotoAdds } from './useProviderPhotoAdds'
|
||||
|
||||
type AddToast = NonNullable<typeof window.__addToast>
|
||||
|
||||
const journeyStoreInitial = useJourneyStore.getState()
|
||||
let addToast: Mock<AddToast>
|
||||
let toEntry: Mock
|
||||
let toGallery: Mock
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <TranslationProvider>{children}</TranslationProvider>
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const reload = vi.fn()
|
||||
const { result } = renderHook(() => useProviderPhotoAdds(reload), { wrapper })
|
||||
return { reload, ...result.current }
|
||||
}
|
||||
|
||||
/** A large add that failed after `added` photos of its earlier batches were stored. */
|
||||
const partial = (added: number) => new ProviderPhotoBatchError({ photos: [], added }, new Error('502'))
|
||||
|
||||
const toasts = () => addToast.mock.calls.map(([message, type]) => [message, type])
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn<AddToast>(() => 0)
|
||||
window.__addToast = addToast
|
||||
toEntry = vi.fn(async () => ({ photos: [], added: 1 }))
|
||||
toGallery = vi.fn(async () => ({ photos: [], added: 1 }))
|
||||
useJourneyStore.setState({ addProviderPhotos: toEntry, addProviderPhotosToGallery: toGallery } as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
useJourneyStore.setState(journeyStoreInitial, true)
|
||||
})
|
||||
|
||||
describe('useProviderPhotoAdds', () => {
|
||||
it('FE-JRN-PPADD-001: the picker sends every group to the gallery in turn, then counts and reloads once', async () => {
|
||||
toGallery.mockResolvedValueOnce({ photos: [], added: 2 }).mockResolvedValueOnce({ photos: [], added: 3 })
|
||||
const { addPickedPhotos, reload } = setup()
|
||||
const first = { assetIds: ['a1', 'a2'], mediaTypes: ['image', 'video'] }
|
||||
const second = { assetIds: ['b1', 'b2', 'b3'], passphrase: 'pw' }
|
||||
|
||||
await addPickedPhotos(9, 'immich', [first, second], null)
|
||||
|
||||
expect(toGallery.mock.calls).toEqual([[9, 'immich', first], [9, 'immich', second]])
|
||||
expect(toEntry).not.toHaveBeenCalled()
|
||||
expect(toasts()).toEqual([['5 photos added', 'success']])
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-JRN-PPADD-002: with an entry picked the groups go to that entry', async () => {
|
||||
const { addPickedPhotos, reload } = setup()
|
||||
const group = { assetIds: ['a1'] }
|
||||
|
||||
await addPickedPhotos(9, 'synologyphotos', [group], 4)
|
||||
|
||||
expect(toEntry).toHaveBeenCalledWith(4, 'synologyphotos', group)
|
||||
expect(toGallery).not.toHaveBeenCalled()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-JRN-PPADD-003: a group that fails outright is reported, and the ones that landed still count', async () => {
|
||||
toGallery.mockResolvedValueOnce({ photos: [], added: 2 }).mockRejectedValueOnce(new Error('offline'))
|
||||
const { addPickedPhotos, reload } = setup()
|
||||
|
||||
await addPickedPhotos(9, 'immich', [{ assetIds: ['a1', 'a2'] }, { assetIds: ['b1'] }], null)
|
||||
|
||||
expect(toasts()).toEqual([['2 photos added', 'success'], ['Error', 'error']])
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-JRN-PPADD-004: nothing added and a failure: only the error, no reload', async () => {
|
||||
toGallery.mockRejectedValueOnce(new Error('offline'))
|
||||
const { addPickedPhotos, reload } = setup()
|
||||
|
||||
await addPickedPhotos(9, 'immich', [{ assetIds: ['a1'] }], null)
|
||||
|
||||
expect(toasts()).toEqual([['Error', 'error']])
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-PPADD-005: a group that fails part way counts the batches it stored, reloads, and still reports the failure (#1587)', async () => {
|
||||
toGallery.mockRejectedValueOnce(partial(500))
|
||||
const { addPickedPhotos, reload } = setup()
|
||||
|
||||
await addPickedPhotos(9, 'immich', [{ assetIds: Array.from({ length: 700 }, (_, i) => `a${i}`) }], null)
|
||||
|
||||
expect(toasts()).toEqual([['500 photos added', 'success'], ['Error', 'error']])
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-JRN-PPADD-006: an add that stored nothing new, every photo already there, says nothing and reloads nothing', async () => {
|
||||
toGallery.mockResolvedValueOnce({ photos: [], added: 0 })
|
||||
const { addPickedPhotos, reload } = setup()
|
||||
|
||||
await addPickedPhotos(9, 'immich', [{ assetIds: ['a1'] }], null)
|
||||
|
||||
expect(addToast).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-PPADD-007: an entry-editor group rethrows every failure, and shows what a partial one stored', async () => {
|
||||
const { addEntryPhotos, reload } = setup()
|
||||
const group = { provider: 'immich', assetIds: ['a1'], passphrase: 'pw', mediaTypes: ['image'] }
|
||||
|
||||
// Success is quiet: the editor reloads the journey once it is done.
|
||||
await addEntryPhotos(4, group)
|
||||
expect(toEntry).toHaveBeenCalledWith(4, 'immich', group)
|
||||
expect(addToast).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
// Part way: the editor keeps the group queued off the rejection, so it has
|
||||
// to come through, and what did land is shown now rather than on Save.
|
||||
const err = partial(500)
|
||||
toEntry.mockRejectedValueOnce(err)
|
||||
await expect(addEntryPhotos(4, group)).rejects.toBe(err)
|
||||
expect(toasts()).toEqual([['500 photos added', 'success']])
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Nothing stored: nothing to show, just the failure.
|
||||
addToast.mockClear()
|
||||
reload.mockClear()
|
||||
toEntry.mockRejectedValueOnce(new Error('offline'))
|
||||
await expect(addEntryPhotos(4, group)).rejects.toThrow('offline')
|
||||
expect(addToast).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { addedBeforeFailure } from '../../api/providerPhotoBatches'
|
||||
import type { ProviderPhotoGroup } from '../../components/Journey/JourneyDetailPageProviderPicker'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
|
||||
/** A group the entry editor queued: the picker's group plus the provider it came from. */
|
||||
export type ProviderEntryGroup = ProviderPhotoGroup & { provider: string }
|
||||
|
||||
/**
|
||||
* Adding provider photos (Immich, Synology Photos) to a journey, for both shells:
|
||||
* the desktop gallery and entry editor, the phone screen and its entry sheet.
|
||||
*
|
||||
* A large add goes out in batches, so it can fail after some of them were stored
|
||||
* (#1587). Those photos are on the journey whatever happens next, so both flows
|
||||
* here count them and reload, and still report the failure for the rest. Before,
|
||||
* such an add read as a plain failure while its photos sat hidden until the next
|
||||
* reload.
|
||||
*
|
||||
* `reload` is how the host fetches the journey again.
|
||||
*/
|
||||
export function useProviderPhotoAdds(reload: () => void) {
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showAdded = (count: number) => {
|
||||
if (count <= 0) return
|
||||
toast.success(t('journey.photosAdded', { count }))
|
||||
reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's Add: every group onto the entry picked in it, or onto the
|
||||
* gallery, then one count, one reload and at most one error.
|
||||
*/
|
||||
const addPickedPhotos = async (
|
||||
journeyId: number,
|
||||
provider: string,
|
||||
groups: ProviderPhotoGroup[],
|
||||
entryId: number | null,
|
||||
): Promise<void> => {
|
||||
const { addProviderPhotos, addProviderPhotosToGallery } = useJourneyStore.getState()
|
||||
let added = 0
|
||||
let anyFailed = false
|
||||
// One group after the other: the server numbers photos in the order they
|
||||
// arrive, and each group is already sorted oldest first.
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const result = entryId
|
||||
? await addProviderPhotos(entryId, provider, group)
|
||||
: await addProviderPhotosToGallery(journeyId, provider, group)
|
||||
added += result.added
|
||||
} catch (err) {
|
||||
added += addedBeforeFailure(err)
|
||||
anyFailed = true
|
||||
}
|
||||
}
|
||||
showAdded(added)
|
||||
if (anyFailed) toast.error(t('common.error'))
|
||||
}
|
||||
|
||||
/**
|
||||
* One group the entry editor queued, sent at Save. Rethrows, so the editor
|
||||
* still reports the group and keeps it queued: sending it again skips what
|
||||
* did land.
|
||||
*/
|
||||
const addEntryPhotos = async (entryId: number, group: ProviderEntryGroup): Promise<void> => {
|
||||
try {
|
||||
await useJourneyStore.getState().addProviderPhotos(entryId, group.provider, group)
|
||||
} catch (err) {
|
||||
showAdded(addedBeforeFailure(err))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return { addPickedPhotos, addEntryPhotos }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-021
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { journeyApi } from '../api/client';
|
||||
@@ -414,6 +414,50 @@ describe('journeyStore', () => {
|
||||
expect(storedEntry?.photos[0].id).toBe(201);
|
||||
});
|
||||
|
||||
// ── provider photos (#1587) ──────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-020: addProviderPhotos sends the group to the entry and answers what was added', async () => {
|
||||
const detail = buildJourneyDetail({ id: 50 });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/provider-photos', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>);
|
||||
return HttpResponse.json({ photos: [{ id: 1 }], added: 1 });
|
||||
})
|
||||
);
|
||||
|
||||
const result = await useJourneyStore.getState().addProviderPhotos(100, 'immich', {
|
||||
assetIds: ['a1'], passphrase: 'pw', mediaTypes: ['video'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ photos: [{ id: 1 }], added: 1 });
|
||||
expect(bodies).toEqual([{ provider: 'immich', asset_ids: ['a1'], passphrase: 'pw', media_types: ['video'] }]);
|
||||
// The caller reloads: where the photo sorts is only known once the server
|
||||
// has its capture time.
|
||||
expect(useJourneyStore.getState().current).toBe(detail);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-021: addProviderPhotosToGallery sends the group to the gallery, and a failure comes through', async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/journeys/50/gallery/provider-photos', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>);
|
||||
return bodies.length === 1
|
||||
? HttpResponse.json({ photos: [], added: 2 })
|
||||
: new HttpResponse(null, { status: 500 });
|
||||
})
|
||||
);
|
||||
|
||||
const result = await useJourneyStore.getState().addProviderPhotosToGallery(50, 'synologyphotos', { assetIds: ['s1', 's2'] });
|
||||
|
||||
expect(result).toEqual({ photos: [], added: 2 });
|
||||
expect(bodies).toEqual([{ provider: 'synologyphotos', asset_ids: ['s1', 's2'] }]);
|
||||
await expect(
|
||||
useJourneyStore.getState().addProviderPhotosToGallery(50, 'synologyphotos', { assetIds: ['s3'] }),
|
||||
).rejects.toMatchObject({ response: { status: 500 } });
|
||||
});
|
||||
|
||||
// ── loadJourney silent refresh ───────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-016: loadJourney does not set loading when refreshing same journey', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { journeyApi } from '../api/client'
|
||||
import type { ProviderPhotosAdded } from '../api/providerPhotoBatches'
|
||||
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
import { captureVideoPoster, isVideoFile } from '../utils/videoPoster'
|
||||
|
||||
@@ -142,6 +143,13 @@ export interface JourneyDetail extends Journey {
|
||||
my_role?: 'owner' | 'editor' | 'viewer'
|
||||
}
|
||||
|
||||
/** Assets picked from one provider, with what the add needs besides their ids. */
|
||||
interface ProviderAssets {
|
||||
assetIds: string[]
|
||||
passphrase?: string
|
||||
mediaTypes?: string[]
|
||||
}
|
||||
|
||||
interface JourneyState {
|
||||
journeys: Journey[]
|
||||
current: JourneyDetail | null
|
||||
@@ -170,6 +178,16 @@ interface JourneyState {
|
||||
|
||||
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||
/**
|
||||
* Photos from a connected provider (Immich, Synology Photos) onto one entry,
|
||||
* or onto the gallery. They answer what was added and leave `current` alone:
|
||||
* the caller reloads the journey, because where a new photo sorts depends on
|
||||
* the capture time the server looks up after answering (#1587). A large add
|
||||
* goes out in batches, and one that fails part way rejects with a
|
||||
* ProviderPhotoBatchError that carries what the earlier batches stored.
|
||||
*/
|
||||
addProviderPhotos: (entryId: number, provider: string, group: ProviderAssets) => Promise<ProviderPhotosAdded>
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, group: ProviderAssets) => Promise<ProviderPhotosAdded>
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||
deletePhoto: (photoId: number) => Promise<void>
|
||||
@@ -376,6 +394,12 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
)
|
||||
},
|
||||
|
||||
addProviderPhotos: (entryId, provider, group) =>
|
||||
journeyApi.addProviderPhotos(entryId, provider, group.assetIds, undefined, group.passphrase, group.mediaTypes),
|
||||
|
||||
addProviderPhotosToGallery: (journeyId, provider, group) =>
|
||||
journeyApi.addProviderPhotosToGallery(journeyId, provider, group.assetIds, group.passphrase, group.mediaTypes),
|
||||
|
||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||
await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
|
||||
set(s => {
|
||||
|
||||
@@ -176,6 +176,8 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
updateEntry: vi.fn(async () => {}),
|
||||
deleteEntry: vi.fn(async () => {}),
|
||||
uploadPhotos: vi.fn(async () => ({ succeeded: [], failed: [] })),
|
||||
addPickedProviderPhotos: vi.fn(async () => {}),
|
||||
addEntryProviderPhotos: vi.fn(async () => {}),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -378,43 +380,37 @@ describe('MJourneyDetail', () => {
|
||||
expect(await screen.findByTestId('provider-picker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-019: adding provider photos reports the count and reloads', async () => {
|
||||
it('FE-MOB-JDET-019: the picker Add goes through the shared provider hook for this journey, then closes the picker', async () => {
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
|
||||
const toGallery = vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockResolvedValue({ added: 3 });
|
||||
const toEntry = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
|
||||
const { hook } = setup({ view: 'gallery' }, UPLOAD_URL);
|
||||
|
||||
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
|
||||
await screen.findByTestId('provider-picker');
|
||||
|
||||
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
|
||||
await onAdd([{ assetIds: ['a1'] }], undefined);
|
||||
expect(toGallery).toHaveBeenCalledWith(12, 'immich', ['a1'], undefined, undefined);
|
||||
expect(toast.success).toHaveBeenCalledWith('journey.photosAdded');
|
||||
expect(hook.loadJourney).toHaveBeenCalledWith(12);
|
||||
|
||||
await onAdd([{ assetIds: ['a2'] }], 1);
|
||||
expect(toEntry).toHaveBeenCalledWith(1, 'immich', ['a2'], undefined, undefined, undefined);
|
||||
// Counting, reloading and reporting a failure, a partial one included, are
|
||||
// the hook's (FE-JRN-PPADD), shared with the desktop gallery.
|
||||
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId: number | null) => Promise<void>;
|
||||
await act(async () => { await onAdd([{ assetIds: ['a1'] }], null); });
|
||||
expect(hook.addPickedProviderPhotos).toHaveBeenCalledWith(12, 'immich', [{ assetIds: ['a1'] }], null);
|
||||
await waitFor(() => expect(screen.queryByTestId('provider-picker')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-020: a failed provider add reports the error instead of a success', async () => {
|
||||
it('FE-MOB-JDET-020: an entry picked in the picker travels with the groups', async () => {
|
||||
vi.mocked(addonsApi.enabled).mockResolvedValue({
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
|
||||
vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockRejectedValue(new Error('boom'));
|
||||
setup({ view: 'gallery' }, UPLOAD_URL);
|
||||
const { hook } = setup({ view: 'gallery' }, UPLOAD_URL);
|
||||
|
||||
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
|
||||
await screen.findByTestId('provider-picker');
|
||||
|
||||
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
|
||||
await onAdd([{ assetIds: ['a1'] }], undefined);
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId: number | null) => Promise<void>;
|
||||
await act(async () => { await onAdd([{ assetIds: ['a2'] }], 1); });
|
||||
expect(hook.addPickedProviderPhotos).toHaveBeenCalledWith(12, 'immich', [{ assetIds: ['a2'] }], 1);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-021: the entry sheet creates a new entry in quick-capture mode', async () => {
|
||||
@@ -614,17 +610,15 @@ describe('MJourneyDetail', () => {
|
||||
|
||||
// ── External photos in the phone entry editor (#1808) ─────────────────────
|
||||
|
||||
it('FE-MOB-JDET-035: the entry sheet gets the provider context and writes the picked photos', async () => {
|
||||
it('FE-MOB-JDET-035: the entry sheet gets the provider context and the shared entry add', () => {
|
||||
const trips = [{ trip_id: 3, added_at: 0, title: 'Tokyo', start_date: '2026-05-01', end_date: '2026-05-04', cover_image: null, currency: 'EUR', place_count: 2 }];
|
||||
const addProvider = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
|
||||
setup({ current: buildDetail({ trips }), editingEntry: buildEntry({ id: 4 }) });
|
||||
const { hook } = setup({ current: buildDetail({ trips }), editingEntry: buildEntry({ id: 4 }) });
|
||||
|
||||
expect(mocks.captured.entrySheet.userId).toBe(5);
|
||||
expect(mocks.captured.entrySheet.trips).toEqual(trips);
|
||||
|
||||
const onAdd = mocks.captured.entrySheet.onAddProviderPhotos as (id: number, g: Record<string, unknown>) => Promise<void>;
|
||||
await onAdd(4, { provider: 'immich', assetIds: ['a1'], passphrase: 'pw', mediaTypes: ['image'] });
|
||||
expect(addProvider).toHaveBeenCalledWith(4, 'immich', ['a1'], undefined, 'pw', ['image']);
|
||||
// The same add the desktop editor gets, so a group that fails part way is
|
||||
// handled once for both shells.
|
||||
expect(mocks.captured.entrySheet.onAddProviderPhotos).toBe(hook.addEntryProviderPhotos);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-036: a retried save updates the entry that was already created', async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
|
||||
import { MemoriesModule } from '../memories/memories.module';
|
||||
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { JourneyDomainModule } from './journey-domain.module';
|
||||
import { JourneyPhotoCaptureModule } from './journey-photo-capture.module';
|
||||
import { JournalRpc } from './journal.rpc';
|
||||
|
||||
/**
|
||||
@@ -11,17 +11,17 @@ import { JournalRpc } from './journal.rpc';
|
||||
*
|
||||
* JournalRpc used to sit in JourneyDomainModule, and it cannot stay there now
|
||||
* that it writes photo bytes: that needs StorageService and, for the EXIF
|
||||
* backfill, PhotoCaptureBackfillService out of MemoriesModule. JourneyDomainModule
|
||||
* exists precisely so places, assignments and the plugin host can reach a few
|
||||
* journey functions WITHOUT dragging MemoriesModule and both photo providers into
|
||||
* their graphs (see the comment there), so the dependency goes here instead and
|
||||
* only the plugin host pays for it.
|
||||
* backfill, JourneyPhotoCaptureModule, which brings MemoriesModule with it.
|
||||
* JourneyDomainModule exists precisely so places, assignments and the plugin
|
||||
* host can reach a few journey functions WITHOUT dragging MemoriesModule and
|
||||
* both photo providers into their graphs (see the comment there), so the
|
||||
* dependency goes here instead and only the plugin host pays for it.
|
||||
*
|
||||
* JourneyDomainModule is re-exported so importing this one is a superset of
|
||||
* importing that one, and nothing that already depended on it has to change.
|
||||
*/
|
||||
@Module({
|
||||
imports: [JourneyDomainModule, StorageModule, AllowedFileTypesModule, MemoriesModule, PluginGuardsModule],
|
||||
imports: [JourneyDomainModule, StorageModule, AllowedFileTypesModule, JourneyPhotoCaptureModule, PluginGuardsModule],
|
||||
providers: [JournalRpc],
|
||||
exports: [JourneyDomainModule],
|
||||
})
|
||||
|
||||
@@ -11,10 +11,10 @@ import { ADDON_IDS } from '../../addons';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { isDemoEmail } from '../common/demo';
|
||||
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
|
||||
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { JourneyPhotoCaptureService } from './journey-photo-capture.service';
|
||||
|
||||
/** 10MB decoded, the same cap the file surface applies to plugin uploads. */
|
||||
const PHOTO_CONTENT_MAX = 10 * 1024 * 1024;
|
||||
@@ -45,7 +45,7 @@ export class JournalRpc {
|
||||
private readonly guards: PluginGuards,
|
||||
private readonly storage: StorageService,
|
||||
private readonly allowedTypes: AllowedFileTypesService,
|
||||
private readonly captureBackfill: PhotoCaptureBackfillService,
|
||||
private readonly photoCapture: JourneyPhotoCaptureService,
|
||||
private readonly db: DatabaseService,
|
||||
) {}
|
||||
|
||||
@@ -181,9 +181,10 @@ export class JournalRpc {
|
||||
await this.storage.delete('journey', filename).catch(() => {});
|
||||
throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
|
||||
}
|
||||
// Best-effort, exactly as the REST route does it: reads EXIF so the photo
|
||||
// carries its capture date.
|
||||
this.captureBackfill.schedule([photo.photo_id].filter((id): id is number => typeof id === 'number'), userId);
|
||||
// Best-effort, exactly as the REST upload does it: reads EXIF so the photo
|
||||
// carries its capture date. One photo per call, so no journey refresh, or a
|
||||
// plugin importing a folder would reload every open client once per photo.
|
||||
this.photoCapture.scheduleUpload([photo], userId);
|
||||
return photo;
|
||||
}
|
||||
|
||||
|
||||
@@ -1500,6 +1500,19 @@ export class JourneyDomainService {
|
||||
|
||||
// ── Photos ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The journey an entry belongs to, or null when there is no such entry.
|
||||
*
|
||||
* No access check: it answers for a caller that has already added photos to
|
||||
* the entry, and only decides which journey hears about their capture times.
|
||||
*/
|
||||
journeyIdOfEntry(entryId: number): number | null {
|
||||
const row = this.db.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as
|
||||
| { journey_id: number }
|
||||
| undefined;
|
||||
return row?.journey_id ?? null;
|
||||
}
|
||||
|
||||
// Promote a skeleton suggestion to a concrete entry. Called whenever the user
|
||||
// adds content (photo upload, provider photo, gallery link) — a suggestion
|
||||
// with photos is no longer just a suggestion.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MemoriesModule } from '../memories/memories.module';
|
||||
import { JourneyDomainModule } from './journey-domain.module';
|
||||
import { JourneyPhotoCaptureService } from './journey-photo-capture.service';
|
||||
|
||||
/**
|
||||
* The capture-time backfill as the journey surfaces use it: fill taken_at, then,
|
||||
* for a batch of provider photos, tell the journey (#1587).
|
||||
*
|
||||
* Its own module because two containers need the same instance of the rule:
|
||||
* JourneyModule (REST routes and the MCP tool) and JournalRpcModule (the plugin
|
||||
* RPC). Neither JourneyDomainModule nor MemoriesModule can own it, the first
|
||||
* because it stays free of the photo providers on purpose and the second
|
||||
* because the memories domain knows nothing about journeys.
|
||||
*/
|
||||
@Module({
|
||||
imports: [JourneyDomainModule, MemoriesModule],
|
||||
providers: [JourneyPhotoCaptureService],
|
||||
exports: [JourneyPhotoCaptureService],
|
||||
})
|
||||
export class JourneyPhotoCaptureModule {}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
|
||||
/** The trek_photos ids of freshly added journey photo rows, whatever shape the add answered with. */
|
||||
function trekPhotoIdsOf(photos: readonly unknown[]): number[] {
|
||||
return photos
|
||||
.map(p => (p as { photo_id?: unknown } | null)?.photo_id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture times for photos that just landed on a journey, and the refresh that
|
||||
* has to follow them (#1587).
|
||||
*
|
||||
* The add answers before anyone has asked the provider when each photo was
|
||||
* taken (#1614 keeps that on the server rather than trusting the client). So
|
||||
* the reload a client does straight after the add still sees the new photos
|
||||
* without taken_at, and the gallery files them at the end by import time. Once
|
||||
* the backfill has written something, everyone on the journey is told, the
|
||||
* importer included: its own reload came too early, which is why the event
|
||||
* excludes no socket.
|
||||
*
|
||||
* One place for the REST routes, the MCP tool and the plugin RPC, so no surface
|
||||
* can add provider photos that stay out of order until somebody reloads.
|
||||
*
|
||||
* Uploads from a device fill their capture times here too, but tell nobody (see
|
||||
* scheduleUpload): one upload request carries one file, so a refresh per request
|
||||
* would reload every open client once per photo of a bulk upload.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JourneyPhotoCaptureService {
|
||||
constructor(
|
||||
private readonly backfill: PhotoCaptureBackfillService,
|
||||
private readonly journey: JourneyDomainService,
|
||||
) {}
|
||||
|
||||
/** Provider photos just added to a journey, to its gallery or to one of its entries. Detached. */
|
||||
scheduleForJourney(journeyId: number, photos: readonly unknown[], userId: number): void {
|
||||
const ids = trekPhotoIdsOf(photos);
|
||||
if (!ids.length) return;
|
||||
void this.fill(journeyId, ids, userId);
|
||||
}
|
||||
|
||||
/** Provider photos just added to an entry, for a caller that only knows the entry. Detached. */
|
||||
scheduleForEntry(entryId: number, photos: readonly unknown[], userId: number): void {
|
||||
const ids = trekPhotoIdsOf(photos);
|
||||
if (!ids.length) return;
|
||||
void this.fill(this.journey.journeyIdOfEntry(entryId), ids, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Photos uploaded from a device: read their capture times, and nothing more.
|
||||
* Detached.
|
||||
*
|
||||
* No refresh on purpose. The client sends one file per upload request and
|
||||
* reloads the journey itself once the last one is in, so an event per request
|
||||
* would only make every other open client reload once per uploaded photo. The
|
||||
* refresh belongs to the provider adds, where one request carries the batch.
|
||||
*/
|
||||
scheduleUpload(photos: readonly unknown[], userId: number): void {
|
||||
const ids = trekPhotoIdsOf(photos);
|
||||
if (!ids.length) return;
|
||||
this.backfill.schedule(ids, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaitable form, so tests do not have to chase a floating promise. Answers
|
||||
* whether the batch changed anything. Never rejects: it runs detached, and a
|
||||
* refresh that could not be sent is a gallery that sorts right on the next
|
||||
* reload, not a failed add.
|
||||
*/
|
||||
async fill(journeyId: number | null, trekPhotoIds: number[], userId: number): Promise<boolean> {
|
||||
let changed = false;
|
||||
try {
|
||||
changed = await this.backfill.run(trekPhotoIds, userId);
|
||||
if (changed && journeyId != null) {
|
||||
this.journey.broadcastJourneyEvent(journeyId, 'journey:photos:updated', {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Journey] capture refresh failed for journey ${journeyId}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { StorageService } from '../storage/storage.service';
|
||||
import { journeyThumbName } from '../memories/thumbnail.service';
|
||||
import { JourneyService } from './journey.service';
|
||||
import { JourneyBookService } from './journey-book.service';
|
||||
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
|
||||
import { JourneyPhotoCaptureService } from './journey-photo-capture.service';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
@@ -130,7 +130,7 @@ export class JourneyController {
|
||||
private readonly journey: JourneyService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly books: JourneyBookService,
|
||||
private readonly captureBackfill: PhotoCaptureBackfillService,
|
||||
private readonly photoCapture: JourneyPhotoCaptureService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -166,16 +166,6 @@ export class JourneyController {
|
||||
}
|
||||
}
|
||||
|
||||
// The add call carries only an asset id, so when and where the picture was taken
|
||||
// are fetched from the provider afterwards rather than trusted from the client
|
||||
// (#1614). Detached: a slow or unreachable provider must not hold up the add.
|
||||
private backfillCapture(photos: unknown[], userId: number): void {
|
||||
const ids = photos
|
||||
.map(p => (p as { photo_id?: number } | null)?.photo_id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
this.captureBackfill.schedule(ids, userId);
|
||||
}
|
||||
|
||||
// ── Static prefix routes (before /:id) ──────────────────────────────────
|
||||
@Get()
|
||||
list(@CurrentUser() user: User) {
|
||||
@@ -263,7 +253,11 @@ export class JourneyController {
|
||||
if (!results.length) {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
this.backfillCapture(results, user.id);
|
||||
// When and where the picture was taken is read from the file afterwards
|
||||
// (#1614). Detached, so the add never waits for it, and without the journey
|
||||
// refresh the provider adds send (#1587): the client uploads one file per
|
||||
// request and reloads once after the last.
|
||||
this.photoCapture.scheduleUpload(results, user.id);
|
||||
return { photos: results };
|
||||
}
|
||||
|
||||
@@ -327,7 +321,7 @@ export class JourneyController {
|
||||
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt);
|
||||
if (photo) added.push(photo);
|
||||
});
|
||||
this.backfillCapture(added, user.id);
|
||||
this.photoCapture.scheduleForEntry(Number(entryId), added, user.id);
|
||||
return { photos: added, added: added.length };
|
||||
}
|
||||
if (!body.provider || !body.asset_id) {
|
||||
@@ -337,7 +331,7 @@ export class JourneyController {
|
||||
if (!photo) {
|
||||
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
|
||||
}
|
||||
this.backfillCapture([photo], user.id);
|
||||
this.photoCapture.scheduleForEntry(Number(entryId), [photo], user.id);
|
||||
return photo;
|
||||
}
|
||||
|
||||
@@ -424,8 +418,9 @@ export class JourneyController {
|
||||
throw new HttpException({ error: 'Not allowed' }, 403);
|
||||
}
|
||||
// An uploaded file carries its own EXIF; reading it is what puts the photo on
|
||||
// the map later. Detached, like the provider branch.
|
||||
this.backfillCapture(photos, user.id);
|
||||
// the map later. Detached, like the provider branch, but without its journey
|
||||
// refresh: one file per request would mean one reload per photo (#1587).
|
||||
this.photoCapture.scheduleUpload(photos, user.id);
|
||||
return { photos };
|
||||
}
|
||||
|
||||
@@ -484,7 +479,7 @@ export class JourneyController {
|
||||
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt);
|
||||
if (photo) added.push(photo);
|
||||
});
|
||||
this.backfillCapture(added, user.id);
|
||||
this.photoCapture.scheduleForJourney(Number(id), added, user.id);
|
||||
return { photos: added, added: added.length };
|
||||
}
|
||||
if (!body.provider || !body.asset_id) {
|
||||
@@ -494,7 +489,7 @@ export class JourneyController {
|
||||
if (!photo) {
|
||||
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
|
||||
}
|
||||
this.backfillCapture([photo], user.id);
|
||||
this.photoCapture.scheduleForJourney(Number(id), [photo], user.id);
|
||||
return photo;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { JourneyContributor } from '../../types';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
|
||||
import { JourneyPhotoCaptureService } from './journey-photo-capture.service';
|
||||
|
||||
/** Legacy registrar gate: the whole journey surface rode the journey addon. */
|
||||
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
|
||||
@@ -110,7 +110,7 @@ export class JourneyMcp {
|
||||
private readonly share: JourneyShareService,
|
||||
readonly addons: AddonsService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly captureBackfill: PhotoCaptureBackfillService,
|
||||
private readonly photoCapture: JourneyPhotoCaptureService,
|
||||
) {}
|
||||
|
||||
// ── Read ────────────────────────────────────────────────────────────────
|
||||
@@ -572,11 +572,9 @@ export class JourneyMcp {
|
||||
|
||||
// Detached, exactly as the REST routes schedule it: the provider is asked
|
||||
// when and where each photo was taken, and without that answer an attached
|
||||
// photo can never appear on the journey map (#1614).
|
||||
this.captureBackfill.schedule(
|
||||
photos.map(p => (p as { photo_id?: number }).photo_id).filter((id): id is number => typeof id === 'number'),
|
||||
ctx.userId,
|
||||
);
|
||||
// photo can never appear on the journey map (#1614). Open clients are told
|
||||
// once it lands, so the gallery re-sorts without a reload (#1587).
|
||||
this.photoCapture.scheduleForJourney(journeyId, photos, ctx.userId);
|
||||
// `skipped` is what tells a caller that a shortfall was duplicates rather
|
||||
// than a failure; the REST body carries only photos and added.
|
||||
return ok({ photos, added: photos.length, skipped: asset_ids.length - photos.length });
|
||||
|
||||
@@ -6,6 +6,7 @@ import { JourneyBookService } from './journey-book.service';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { MemoriesModule } from '../memories/memories.module';
|
||||
import { JourneyDomainModule } from './journey-domain.module';
|
||||
import { JourneyPhotoCaptureModule } from './journey-photo-capture.module';
|
||||
import { JourneyMcp } from './journey.mcp';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
@@ -33,7 +34,7 @@ import { journeyImageFileFilter, journeyUploadFilename } from './journey.control
|
||||
}),
|
||||
}),
|
||||
StorageModule,
|
||||
AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule],
|
||||
AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule, JourneyPhotoCaptureModule],
|
||||
controllers: [JourneyController, JourneyPublicController],
|
||||
providers: [JourneyService, JourneyBookService, JourneyMcp],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared';
|
||||
import type { Response } from 'express';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { checkSsrf, safeFetch, type SafeFetchOptions } from '../../utils/ssrfGuard';
|
||||
@@ -13,7 +14,7 @@ import { describeFetchFailure, fail, handleServiceResult, isWithinLocalDayRange,
|
||||
const ALBUM_PAGE_SIZE = 1000;
|
||||
const ALBUM_MAX_PAGES = 20;
|
||||
/**
|
||||
* How many upstream pages one search may read while filling one answered page.
|
||||
* How many upstream pages one search may spend on rows the day filter drops.
|
||||
*
|
||||
* The day filter runs after the fetch, so the number of raw pages an answered
|
||||
* page costs depends on how much of the padding days sits in front of it. This
|
||||
@@ -21,8 +22,29 @@ const ALBUM_MAX_PAGES = 20;
|
||||
* would otherwise keep a single request fetching, so the scan stops here and
|
||||
* answers `hasMore: false` rather than spinning or handing back the same
|
||||
* partial page for every page the caller asks for.
|
||||
*
|
||||
* It is an allowance on top of the pages the answered ones need, not a ceiling
|
||||
* on the scan. Every call restarts at raw page 1, so page N reads at least N-1
|
||||
* raw pages before it reaches its own, and a flat budget of 20 ended every
|
||||
* date-filtered search at 20 answered pages: 1,000 photos at the picker's old
|
||||
* page size, with no word to the user (#1587).
|
||||
*/
|
||||
const SEARCH_MAX_RAW_PAGES = 20;
|
||||
/**
|
||||
* The hard stop behind that allowance: no single search reads more raw pages
|
||||
* than this, whatever page it asks for. `page` comes from the caller unbounded,
|
||||
* and a purely relative budget would walk 100,000 raw pages to answer page
|
||||
* 100,000.
|
||||
*
|
||||
* Sized for the journey picker, which never asks a date-bounded search for a
|
||||
* page past PROVIDER_SELECT_ALL_MAX_PAGES + 1 (251), whether "Select all" loads
|
||||
* it, a second press does, or it is scrolled into view: photos 50,001 to 50,200
|
||||
* at its 200 a page. Page N may read N - 1 + SEARCH_MAX_RAW_PAGES raw pages, so
|
||||
* page 251 needs 250 + 20 = 270, and that is the ceiling. A deeper page, which
|
||||
* only another caller such as the MCP tool asks for, gets a smaller allowance,
|
||||
* and one past the ceiling can only come back empty with `hasMore: false`.
|
||||
*/
|
||||
const SEARCH_ABSOLUTE_MAX_RAW_PAGES = PROVIDER_SELECT_ALL_MAX_PAGES + SEARCH_MAX_RAW_PAGES;
|
||||
/** A mirrored journey upload is one photo; a server that sits on it this long is not coming back. */
|
||||
const UPLOAD_TIMEOUT_MS = 60_000;
|
||||
|
||||
@@ -331,7 +353,10 @@ export class ImmichService {
|
||||
const narrowed = Boolean(from || to);
|
||||
const skip = narrowed ? (page - 1) * size : 0;
|
||||
const wanted = skip + size;
|
||||
const maxRawPages = narrowed ? SEARCH_MAX_RAW_PAGES : 1;
|
||||
// The pages before this one, read even when every row on them is kept, plus
|
||||
// the allowance for rows the filter drops. Page 1 keeps its budget of 20, and
|
||||
// no page reads past the absolute ceiling.
|
||||
const maxRawPages = narrowed ? Math.min(page - 1 + SEARCH_MAX_RAW_PAGES, SEARCH_ABSOLUTE_MAX_RAW_PAGES) : 1;
|
||||
|
||||
const kept: any[] = [];
|
||||
let rawPage = narrowed ? 1 : page;
|
||||
|
||||
@@ -4,6 +4,26 @@ import { PhotoResolverService } from './photo-resolver.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
|
||||
/**
|
||||
* Provider lookups one user keeps in flight at once, across every run of theirs.
|
||||
*
|
||||
* Per user rather than per run: the picker sends a large import in batches of
|
||||
* 500, every batch starts its own detached run, and a per-run cap let those runs
|
||||
* add up to several times this against the same NAS. Per user rather than for
|
||||
* the whole server: each lookup goes to the importer's own Immich or Synology,
|
||||
* so a NAS that has stopped answering must not hold up the capture times of
|
||||
* everybody else on the instance. The service is a singleton of MemoriesModule,
|
||||
* so a user's imports queue for the same slots whichever surface sent them.
|
||||
*/
|
||||
const BACKFILL_CONCURRENCY = 4;
|
||||
|
||||
/** One user's lookup slots: how many are open, and who waits for the next. */
|
||||
interface LookupSlots {
|
||||
active: number;
|
||||
/** First come first served. */
|
||||
waiting: Array<() => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the provider when and where a photo was taken, and record it (#1614).
|
||||
*
|
||||
@@ -19,6 +39,13 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhotoCaptureBackfillService {
|
||||
/**
|
||||
* Open lookups per user, never above BACKFILL_CONCURRENCY for any of them. An
|
||||
* entry lives only while its user has a lookup open or waiting, so the map
|
||||
* does not grow with every user who ever imported a photo.
|
||||
*/
|
||||
private readonly slots = new Map<number, LookupSlots>();
|
||||
|
||||
constructor(
|
||||
private readonly resolver: PhotoResolverService,
|
||||
private readonly photos: TrekPhotosRepository,
|
||||
@@ -31,34 +58,104 @@ export class PhotoCaptureBackfillService {
|
||||
void this.run(trekPhotoIds, userId);
|
||||
}
|
||||
|
||||
/** Awaitable form, so tests do not have to chase a floating promise. */
|
||||
async run(trekPhotoIds: number[], userId: number): Promise<void> {
|
||||
for (const id of trekPhotoIds) {
|
||||
try {
|
||||
const photo = this.photos.resolve(id);
|
||||
// A row that already knows both has nothing to gain, and a provider call
|
||||
// per photo is the expensive part of an album import.
|
||||
if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue;
|
||||
|
||||
// A local file has no provider to ask — the answer is in its own EXIF, and
|
||||
// getPhotoInfo would only hand back what the DB row already says.
|
||||
if (photo.provider === 'local') {
|
||||
const meta = await this.readLocalExif(photo.file_path);
|
||||
if (meta) this.photos.recordCaptureMetadata(id, meta);
|
||||
continue;
|
||||
}
|
||||
|
||||
const info = await this.resolver.getPhotoInfo(userId, id);
|
||||
if (!info.success) continue;
|
||||
|
||||
this.photos.recordCaptureMetadata(id, {
|
||||
takenAt: info.data.takenAt ?? null,
|
||||
lat: info.data.lat ?? null,
|
||||
lng: info.data.lng ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[Photos] capture backfill failed for ${id}:`, err instanceof Error ? err.message : err);
|
||||
/**
|
||||
* Awaitable form: resolves once every photo of the batch has been tried, with
|
||||
* whether any of them learned something. The journey refresh hangs off that
|
||||
* answer (#1587).
|
||||
*
|
||||
* A few photos at a time rather than one after the other: every provider call
|
||||
* can take seconds, and a strictly sequential walk kept a large import out of
|
||||
* order for minutes. The cap is shared by every run of the same user, so a
|
||||
* thousand-photo import, however many batches it arrives in, never has more
|
||||
* than BACKFILL_CONCURRENCY lookups open against that user's NAS.
|
||||
*/
|
||||
async run(trekPhotoIds: number[], userId: number): Promise<boolean> {
|
||||
let next = 0;
|
||||
let changed = false;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (next < trekPhotoIds.length) {
|
||||
const id = trekPhotoIds[next++];
|
||||
if (await this.fillOne(id, userId)) changed = true;
|
||||
}
|
||||
};
|
||||
const workers = Math.min(BACKFILL_CONCURRENCY, trekPhotoIds.length);
|
||||
await Promise.all(Array.from({ length: workers }, worker));
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider lookup inside the user's cap. The slot is given back whatever
|
||||
* the lookup does: a slot lost to a throw would stay lost, and four of them
|
||||
* would stall every later import of that user for good.
|
||||
*/
|
||||
private async withLookupSlot<T>(userId: number, lookup: () => Promise<T>): Promise<T> {
|
||||
const slots = await this.acquireSlot(userId);
|
||||
try {
|
||||
return await lookup();
|
||||
} finally {
|
||||
this.releaseSlot(userId, slots);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for one of the user's lookup slots, and answer the entry it came from. */
|
||||
private async acquireSlot(userId: number): Promise<LookupSlots> {
|
||||
let slots = this.slots.get(userId);
|
||||
if (!slots) {
|
||||
slots = { active: 0, waiting: [] };
|
||||
this.slots.set(userId, slots);
|
||||
}
|
||||
if (slots.active < BACKFILL_CONCURRENCY) {
|
||||
slots.active++;
|
||||
return slots;
|
||||
}
|
||||
// releaseSlot hands its slot straight to the first waiter, so the count
|
||||
// stays where it is and nobody can slip in between.
|
||||
const { waiting } = slots;
|
||||
await new Promise<void>(resolve => {
|
||||
waiting.push(resolve);
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
/** Give a slot back: to the user's longest waiter, or, once nobody holds one, drop the entry. */
|
||||
private releaseSlot(userId: number, slots: LookupSlots): void {
|
||||
const next = slots.waiting.shift();
|
||||
if (next) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
slots.active--;
|
||||
if (slots.active === 0) this.slots.delete(userId);
|
||||
}
|
||||
|
||||
/** One photo; answers whether its row changed. */
|
||||
private async fillOne(id: number, userId: number): Promise<boolean> {
|
||||
try {
|
||||
const photo = this.photos.resolve(id);
|
||||
// A row that already knows both has nothing to gain, and a provider call
|
||||
// per photo is the expensive part of an album import.
|
||||
if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) return false;
|
||||
|
||||
// A local file has no provider to ask: the answer is in its own EXIF, and
|
||||
// getPhotoInfo would only hand back what the DB row already says. Nor does
|
||||
// it take a lookup slot, which exists to spare a NAS: a device upload must
|
||||
// not wait behind the same user's provider import.
|
||||
if (photo.provider === 'local') {
|
||||
const meta = await this.readLocalExif(photo.file_path);
|
||||
return meta ? this.photos.recordCaptureMetadata(id, meta) : false;
|
||||
}
|
||||
|
||||
const info = await this.withLookupSlot(userId, () => this.resolver.getPhotoInfo(userId, id));
|
||||
if (!info.success) return false;
|
||||
|
||||
return this.photos.recordCaptureMetadata(id, {
|
||||
takenAt: info.data.takenAt ?? null,
|
||||
lat: info.data.lat ?? null,
|
||||
lng: info.data.lng ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[Photos] capture backfill failed for ${id}:`, err instanceof Error ? err.message : err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,24 +99,35 @@ export class TrekPhotosRepository {
|
||||
* and a later, emptier answer must not blank what is already there.
|
||||
*
|
||||
* A photo with neither is the normal case, not a failure.
|
||||
*
|
||||
* Answers whether the row learned anything. The guard in the WHERE clause is
|
||||
* what makes that answer mean something: SQLite counts a matched row as changed
|
||||
* even when every COALESCE kept the old value, and the caller broadcasts a
|
||||
* journey refresh on it (#1587).
|
||||
*/
|
||||
recordCaptureMetadata(
|
||||
photoId: number,
|
||||
meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
|
||||
): void {
|
||||
): boolean {
|
||||
const { takenAt = null, lat = null, lng = null } = meta;
|
||||
if (takenAt == null && lat == null && lng == null) return;
|
||||
if (takenAt == null && lat == null && lng == null) return false;
|
||||
// Coordinates are stored as a pair or not at all — a lone latitude is not a
|
||||
// place, and half a pair would put the photo on the null island.
|
||||
const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
|
||||
this.db.run(
|
||||
const pairLat = hasPair ? lat : null;
|
||||
const pairLng = hasPair ? lng : null;
|
||||
const result = this.db.run(
|
||||
`UPDATE trek_photos
|
||||
SET taken_at = COALESCE(taken_at, ?),
|
||||
lat = COALESCE(lat, ?),
|
||||
lng = COALESCE(lng, ?)
|
||||
WHERE id = ?`,
|
||||
takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
|
||||
WHERE id = ?
|
||||
AND ((taken_at IS NULL AND ? IS NOT NULL)
|
||||
OR (lat IS NULL AND ? IS NOT NULL)
|
||||
OR (lng IS NULL AND ? IS NOT NULL))`,
|
||||
takenAt, pairLat, pairLng, photoId, takenAt, pairLat, pairLng,
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import path from 'node:path';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
@@ -50,10 +51,18 @@ const { jsvc } = vi.hoisted(() => ({
|
||||
jsvc: {
|
||||
listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(),
|
||||
journeyStats: vi.fn(), updateEntry: vi.fn(), restoreDismissedSuggestions: vi.fn(),
|
||||
addProviderPhoto: vi.fn(), addProviderPhotoToGallery: vi.fn(), uploadGalleryPhotos: vi.fn(),
|
||||
broadcastJourneyEvent: vi.fn(), journeyIdOfEntry: vi.fn(),
|
||||
},
|
||||
}));
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
|
||||
// The capture-time lookup itself asks a real provider; what these cases pin is
|
||||
// the wiring around it: the route answers, the backfill runs detached, and the
|
||||
// journey hears about it after a provider add, never after an upload (#1587).
|
||||
const { backfillRun, backfillSchedule } = vi.hoisted(() => ({ backfillRun: vi.fn(), backfillSchedule: vi.fn() }));
|
||||
import { PhotoCaptureBackfillService } from '../../src/nest/memories/photo-capture-backfill.service';
|
||||
|
||||
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
|
||||
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
|
||||
|
||||
@@ -67,6 +76,7 @@ import { JourneyBookService } from '../../src/nest/journey/journey-book.service'
|
||||
import { MAX_SPREAD_ELEMENTS } from '@trek/shared';
|
||||
|
||||
import { JourneyModule } from '../../src/nest/journey/journey.module';
|
||||
import { StorageService } from '../../src/nest/storage/storage.service';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
@@ -85,6 +95,8 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
.useValue(booksvc)
|
||||
.overrideProvider(AddonsService)
|
||||
.useValue({ isAddonEnabled })
|
||||
.overrideProvider(PhotoCaptureBackfillService)
|
||||
.useValue({ run: backfillRun, schedule: backfillSchedule })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
@@ -415,6 +427,80 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.body).toEqual({ error: 'Not allowed' });
|
||||
});
|
||||
|
||||
it('a gallery provider-photo add answers at once, then tells the journey when the capture times land (#1587)', async () => {
|
||||
jsvc.broadcastJourneyEvent.mockReset();
|
||||
jsvc.addProviderPhotoToGallery.mockReturnValue({ id: 5, journey_id: 9, photo_id: 77 });
|
||||
backfillRun.mockReset().mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.post('/api/journeys/9/gallery/provider-photos')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ provider: 'immich', asset_ids: ['a1'], media_types: ['image'] });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ photos: [{ id: 5, journey_id: 9, photo_id: 77 }], added: 1 });
|
||||
await vi.waitFor(() => expect(jsvc.broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {}));
|
||||
expect(backfillRun).toHaveBeenCalledWith([77], 1);
|
||||
});
|
||||
|
||||
it('an entry provider-photo add is told to the journey of that entry, and only when something was learned', async () => {
|
||||
jsvc.broadcastJourneyEvent.mockReset();
|
||||
jsvc.addProviderPhoto.mockReturnValue({ id: 6, entry_id: 3, photo_id: 78 });
|
||||
jsvc.journeyIdOfEntry.mockReturnValue(9);
|
||||
backfillRun.mockReset().mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.post('/api/journeys/entries/3/provider-photos')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ provider: 'immich', asset_ids: ['a2'] });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ photos: [{ id: 6, entry_id: 3, photo_id: 78 }], added: 1 });
|
||||
await vi.waitFor(() => expect(jsvc.broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {}));
|
||||
expect(jsvc.journeyIdOfEntry).toHaveBeenCalledWith(3);
|
||||
|
||||
// Nothing learned: the photos are where they already were, so no refresh.
|
||||
jsvc.broadcastJourneyEvent.mockReset();
|
||||
backfillRun.mockReset().mockResolvedValue(false);
|
||||
await request(server)
|
||||
.post('/api/journeys/entries/3/provider-photos')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.send({ provider: 'immich', asset_ids: ['a3'] });
|
||||
await vi.waitFor(() => expect(backfillRun).toHaveBeenCalled());
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(jsvc.broadcastJourneyEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a gallery upload gets its capture time read, and no journey refresh: the client sends one file per request (#1587)', async () => {
|
||||
jsvc.broadcastJourneyEvent.mockReset();
|
||||
backfillRun.mockReset().mockResolvedValue(true);
|
||||
backfillSchedule.mockReset();
|
||||
jsvc.uploadGalleryPhotos.mockImplementation((_id: number, _userId: number, files: Array<{ path: string }>) =>
|
||||
files.map((f, i) => ({ id: 30 + i, journey_id: 9, photo_id: 90 + i, file_path: f.path })));
|
||||
|
||||
const res = await request(server)
|
||||
.post('/api/journeys/9/gallery/photos')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.attach('photos', path.join(__dirname, '../fixtures/small-image.jpg'));
|
||||
|
||||
try {
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.photos).toHaveLength(1);
|
||||
// The plain backfill, the same one the provider adds run...
|
||||
expect(backfillSchedule).toHaveBeenCalledWith([90], 1);
|
||||
// ...but not the refreshing run behind it: a bulk upload would otherwise
|
||||
// reload every open client once per photo.
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(backfillRun).not.toHaveBeenCalled();
|
||||
expect(jsvc.broadcastJourneyEvent).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
const storage = app.get(StorageService);
|
||||
for (const photo of (res.body.photos ?? []) as Array<{ file_path: string }>) {
|
||||
await storage.delete('journey', photo.file_path.slice('journey/'.length));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('public journey 404 for an unknown token', async () => {
|
||||
sharesvc.getPublicJourney.mockReturnValueOnce(null);
|
||||
const res = await request(server).get('/api/public/journey/bad');
|
||||
|
||||
@@ -95,6 +95,7 @@ import { ImmichService } from '../../src/nest/memories/immich.service';
|
||||
import { SynologyService } from '../../src/nest/memories/synology.service';
|
||||
import { MemoriesAccessService } from '../../src/nest/memories/memories-access.service';
|
||||
import { PhotoCaptureBackfillService } from '../../src/nest/memories/photo-capture-backfill.service';
|
||||
import { JourneyPhotoCaptureService } from '../../src/nest/journey/journey-photo-capture.service';
|
||||
import { PhotoResolverService } from '../../src/nest/memories/photo-resolver.service';
|
||||
import { ThumbnailService } from '../../src/nest/memories/thumbnail.service';
|
||||
import { TrekPhotoCacheService } from '../../src/nest/memories/trek-photo-cache.service';
|
||||
@@ -245,7 +246,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService),
|
||||
new TransitMcp(new TransitService(new GoogleTransitProvider(dbService)), daysService, reservationsService, dbService, authService, guards),
|
||||
new AtlasMcp(new AtlasService(dbService), addonsService, authService),
|
||||
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService, captureBackfill),
|
||||
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService, new JourneyPhotoCaptureService(captureBackfill, journeyDomain)),
|
||||
new MemoriesMcp(immichService, synologyService, dbService, addonsService),
|
||||
new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService),
|
||||
new AirtrailMcp(new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient()), addonsService),
|
||||
|
||||
@@ -139,7 +139,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
|
||||
new VacayRpc(vacay, guards),
|
||||
// The photo half needs storage plus the allowed-types setting and the EXIF
|
||||
// backfill; none of the tests on this harness write bytes, so they are stubs.
|
||||
new JournalRpc(journey, guards, generalStorage, { get: () => '*' } as never, { schedule: () => {} } as never, dbs),
|
||||
new JournalRpc(journey, guards, generalStorage, { get: () => '*' } as never, { scheduleUpload: () => {} } as never, dbs),
|
||||
new CollectionsRpc(collections, guards),
|
||||
new DbRpc(new PluginUserSettingsService(dbs)),
|
||||
new MetaRpc(dbs, guards),
|
||||
|
||||
@@ -982,6 +982,52 @@ describe('Immich searchPhotos pagination pass-through', () => {
|
||||
expect(res.body.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('IMMICH-094: POST /search reaches a date-filtered page past the twentieth at the picker page size (#1587)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Every raw page is 200 photos of the searched day, so answered page 21
|
||||
// needs raw pages 1 to 21. The old flat budget of 20 answered it empty with
|
||||
// hasMore false, which is where a trip stopped without a word.
|
||||
const previous = vi.mocked(safeFetch).getMockImplementation();
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
vi.mocked(safeFetch).mockImplementation(async (_url: unknown, init?: { body?: unknown }) => {
|
||||
const rawPage = JSON.parse(String(init?.body ?? '{}')).page as number;
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 200 }, (_, i) => ({
|
||||
id: `deep-${rawPage}-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
localDateTime: '2024-06-01T12:00:00.000Z',
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as never;
|
||||
});
|
||||
|
||||
let res: request.Response;
|
||||
try {
|
||||
res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ from: '2024-06-01', to: '2024-06-01', page: 21, size: 200 });
|
||||
} finally {
|
||||
if (previous) vi.mocked(safeFetch).mockImplementation(previous);
|
||||
}
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assets).toHaveLength(200);
|
||||
expect(res.body.assets[0].id).toMatch(/^deep-21-/);
|
||||
expect(res.body.hasMore).toBe(true);
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(21);
|
||||
const sizes = vi.mocked(safeFetch).mock.calls.map(c => JSON.parse(String((c[1] as { body?: unknown }).body)).size);
|
||||
expect(new Set(sizes)).toEqual(new Set([200]));
|
||||
});
|
||||
|
||||
it('IMMICH-093 — POST /search requests timeline visibility so Immich v3 never returns hidden assets (#1474)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
@@ -55,8 +55,9 @@ const synologySearch = vi.spyOn(SynologyService.prototype, 'searchSynologyPhotos
|
||||
const synologyAlbums = vi.spyOn(SynologyService.prototype, 'listSynologyAlbums');
|
||||
const synologyAlbumPhotos = vi.spyOn(SynologyService.prototype, 'getSynologyAlbumPhotos');
|
||||
// Detached in production; held still here so a case can assert what was queued
|
||||
// without the provider lookup it would otherwise fire.
|
||||
const backfillSchedule = vi.spyOn(PhotoCaptureBackfillService.prototype, 'schedule').mockImplementation(() => {});
|
||||
// without the provider lookup it would otherwise fire. It answers "a row learned
|
||||
// its capture time", which is what makes the journey refresh go out.
|
||||
const backfillRun = vi.spyOn(PhotoCaptureBackfillService.prototype, 'run').mockResolvedValue(true);
|
||||
|
||||
const IMMICH_ASSET = { id: 'a1', takenAt: '2026-07-01T10:00:00.000Z', city: 'Rome', country: 'IT', lat: 41.9, lng: 12.5, mediaType: 'image' };
|
||||
const SYNOLOGY_ASSET = { id: 's1', takenAt: '2026-07-02T10:00:00.000Z', lat: 48.1, lng: 11.6 };
|
||||
@@ -94,7 +95,7 @@ beforeEach(() => {
|
||||
synologySearch.mockReset().mockResolvedValue({ success: true, data: { assets: [SYNOLOGY_ASSET], total: 1, hasMore: false } });
|
||||
synologyAlbums.mockReset().mockResolvedValue({ success: true, data: { albums: [{ id: '7', albumName: 'Munich', assetCount: 3, passphrase: 'pp' }] } });
|
||||
synologyAlbumPhotos.mockReset().mockResolvedValue({ success: true, data: { assets: [SYNOLOGY_ASSET], total: 1, hasMore: false } });
|
||||
backfillSchedule.mockClear();
|
||||
backfillRun.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -466,7 +467,25 @@ describe('Tool: add_journey_provider_photos', () => {
|
||||
arguments: { journeyId: journey.id, entryId: entry.id, provider: 'immich', asset_ids: ['bf-1'] },
|
||||
});
|
||||
const photoId = (testDb.prepare('SELECT id FROM trek_photos WHERE asset_id = ? AND owner_id = ?').get('bf-1', user.id) as { id: number }).id;
|
||||
expect(backfillSchedule).toHaveBeenCalledWith([photoId], user.id);
|
||||
expect(backfillRun).toHaveBeenCalledWith([photoId], user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('tells the journey once the capture times have landed, exactly as the REST routes do', async () => {
|
||||
// Same shared rule as POST /api/journeys/:id/gallery/provider-photos: the
|
||||
// gallery re-sorts on the event instead of waiting for a reload (#1587).
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'add_journey_provider_photos',
|
||||
arguments: { journeyId: journey.id, provider: 'immich', asset_ids: ['bf-2'] },
|
||||
});
|
||||
await vi.waitFor(() => expect(broadcastMock).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
expect.objectContaining({ type: 'journey:photos:updated', journeyId: journey.id }),
|
||||
undefined,
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import type { MemoriesAccessService } from '../../../src/nest/memories/memories-
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { PROVIDER_SELECT_ALL_MAX_PAGES } from '@trek/shared';
|
||||
|
||||
const audit = { writeAudit: vi.fn() };
|
||||
const access = { getAlbumIdFromLink: vi.fn() };
|
||||
@@ -453,6 +454,91 @@ describe('searchPhotos', () => {
|
||||
expect(safeFetch).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
|
||||
it('IMMICH-050e: a page past the twentieth still answers on a window full of the searched day', async () => {
|
||||
// The budget used to be 20 raw pages whatever page was asked for, and every
|
||||
// call restarts at raw page 1, so answered page 21 onwards was always empty
|
||||
// with hasMore false: a trip stopped at 1,000 photos without a word (#1587).
|
||||
safeFetch.mockImplementation(async (_url: string, init: { body: string }) => {
|
||||
const rawPage = JSON.parse(init.body).page as number;
|
||||
return upstream({ json: { assets: { items: [
|
||||
{ id: `d-${rawPage}-1`, fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' },
|
||||
{ id: `d-${rawPage}-2`, fileCreatedAt: '2026-03-15T08:00:00.000Z', localDateTime: '2026-03-15T19:00:00.000Z' },
|
||||
] } } });
|
||||
});
|
||||
|
||||
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 25, 2);
|
||||
|
||||
expect(result.assets!.map(a => a.id)).toEqual(['d-25-1', 'd-25-2']);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(safeFetch).toHaveBeenCalledTimes(25);
|
||||
});
|
||||
|
||||
it('IMMICH-050f: a deep page still stops once its own allowance of padding pages is spent', async () => {
|
||||
// Page 3 needs the two pages in front of it plus the same allowance page 1
|
||||
// gets, and a window of nothing but the neighbouring day never fills it.
|
||||
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } }));
|
||||
|
||||
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 3, 2);
|
||||
|
||||
expect(result.assets).toEqual([]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(safeFetch).toHaveBeenCalledTimes(22);
|
||||
});
|
||||
|
||||
it('IMMICH-050g: a huge page stops at the absolute ceiling instead of walking the whole library', async () => {
|
||||
// page is not bounded by the routes, and the relative allowance alone would
|
||||
// have read a million raw pages here before answering.
|
||||
safeFetch.mockImplementation(async (_url: string, init: { body: string }) => {
|
||||
const rawPage = JSON.parse(init.body).page as number;
|
||||
return upstream({ json: { assets: { items: [
|
||||
{ id: `h-${rawPage}-1`, fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z' },
|
||||
{ id: `h-${rawPage}-2`, fileCreatedAt: '2026-03-15T08:00:00.000Z', localDateTime: '2026-03-15T19:00:00.000Z' },
|
||||
] } } });
|
||||
});
|
||||
|
||||
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', 1_000_000, 2);
|
||||
|
||||
expect(result.assets).toEqual([]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
// The ceiling: the pages in front of the picker's deepest page plus its
|
||||
// allowance, 250 + 20.
|
||||
expect(safeFetch).toHaveBeenCalledTimes(270);
|
||||
});
|
||||
|
||||
it('IMMICH-050h: the deepest page the picker asks for still gets its full allowance under the ceiling', async () => {
|
||||
// The picker never asks a date-bounded search past page 251, by Select all
|
||||
// or by scrolling: 250 pages in front of it plus the allowance of 20.
|
||||
safeFetch.mockResolvedValue(upstream({ json: { assets: { items: NEXT_DAY } } }));
|
||||
|
||||
const deepest = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 1, 2);
|
||||
|
||||
expect(deepest.hasMore).toBe(false);
|
||||
expect(safeFetch).toHaveBeenCalledTimes(270);
|
||||
|
||||
// One page further the ceiling already bites: the allowance shrinks by one.
|
||||
safeFetch.mockClear();
|
||||
await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 2, 2);
|
||||
expect(safeFetch).toHaveBeenCalledTimes(270);
|
||||
});
|
||||
|
||||
it('IMMICH-050i: at the picker page size of 200 the deepest page it asks for answers in full', async () => {
|
||||
// A trip of more than 50,000 photos, every raw page full of the searched
|
||||
// day: page 251 is photos 50,001 to 50,200, read off raw page 251.
|
||||
safeFetch.mockImplementation(async (_url: string, init: { body: string }) => {
|
||||
const { page, size } = JSON.parse(init.body) as { page: number; size: number };
|
||||
return upstream({ json: { assets: { items: Array.from({ length: size }, (_, i) => ({
|
||||
id: `p-${page}-${i}`, fileCreatedAt: '2026-03-15T09:00:00.000Z', localDateTime: '2026-03-15T20:00:00.000Z',
|
||||
})) } } });
|
||||
});
|
||||
|
||||
const result = await svc.searchPhotos(USER, '2026-03-15', '2026-03-15', PROVIDER_SELECT_ALL_MAX_PAGES + 1, 200);
|
||||
|
||||
expect(result.assets).toHaveLength(200);
|
||||
expect(result.assets!.every(a => a.id.startsWith('p-251-'))).toBe(true);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(safeFetch).toHaveBeenCalledTimes(251);
|
||||
});
|
||||
|
||||
it('IMMICH-050d: an unfiltered browse still costs one round trip, at the page it was asked for', async () => {
|
||||
// Nothing narrows those pages, so a raw page and an answered page are the
|
||||
// same page and the scan must not start over at page 1.
|
||||
|
||||
@@ -2721,3 +2721,16 @@ describe('entry field switches', () => {
|
||||
expect(svc.updateJourney(journey.id, editor.id, { show_mood: false })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('journeyIdOfEntry', () => {
|
||||
it('JOURNEY-SVC-ENTRY-JOURNEY-001: names the journey an entry sits in, and null for an entry that does not exist', () => {
|
||||
// The capture refresh (#1587) only knows the entry the photos went to and has
|
||||
// to tell the journey they belong to.
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
||||
|
||||
expect(svc.journeyIdOfEntry(entry.id)).toBe(journey.id);
|
||||
expect(svc.journeyIdOfEntry(999999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* JourneyPhotoCaptureService (#1587): the capture-time backfill as every journey
|
||||
* surface runs it, and the refresh that has to follow it so the gallery re-sorts
|
||||
* without anyone reloading.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service';
|
||||
import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
|
||||
import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import type { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
|
||||
import type { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
|
||||
function build(changed: boolean | Error = true) {
|
||||
const run = vi.fn(async () => {
|
||||
if (changed instanceof Error) throw changed;
|
||||
return changed;
|
||||
});
|
||||
const schedule = vi.fn();
|
||||
const broadcastJourneyEvent = vi.fn();
|
||||
const journeyIdOfEntry = vi.fn((entryId: number) => (entryId === 4 ? 9 : null));
|
||||
const svc = new JourneyPhotoCaptureService(
|
||||
{ run, schedule } as unknown as PhotoCaptureBackfillService,
|
||||
{ broadcastJourneyEvent, journeyIdOfEntry } as unknown as JourneyDomainService,
|
||||
);
|
||||
return { svc, run, schedule, broadcastJourneyEvent, journeyIdOfEntry };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('JourneyPhotoCaptureService', () => {
|
||||
it('JPCAP-001: fills the added photos, then tells everyone on the journey, the importer included', async () => {
|
||||
const { svc, run, broadcastJourneyEvent } = build(true);
|
||||
|
||||
svc.scheduleForJourney(9, [{ id: 1, photo_id: 11 }, { id: 2, photo_id: 12 }], 3);
|
||||
|
||||
await vi.waitFor(() => expect(broadcastJourneyEvent).toHaveBeenCalled());
|
||||
expect(run).toHaveBeenCalledWith([11, 12], 3);
|
||||
// No socket is excluded: the importer's own reload came before the backfill.
|
||||
expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {});
|
||||
});
|
||||
|
||||
it('JPCAP-002: says nothing when the backfill learned nothing', async () => {
|
||||
const { svc, broadcastJourneyEvent } = build(false);
|
||||
|
||||
await expect(svc.fill(9, [11], 3)).resolves.toBe(false);
|
||||
expect(broadcastJourneyEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('JPCAP-003: an entry add is reported against the journey that entry belongs to', async () => {
|
||||
const { svc, journeyIdOfEntry, broadcastJourneyEvent } = build(true);
|
||||
|
||||
svc.scheduleForEntry(4, [{ id: 1, photo_id: 11 }], 3);
|
||||
|
||||
await vi.waitFor(() => expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {}));
|
||||
expect(journeyIdOfEntry).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('JPCAP-004: an entry that has gone still gets its photos filled, with no one to tell', async () => {
|
||||
const { svc, run, broadcastJourneyEvent } = build(true);
|
||||
|
||||
await expect(svc.fill(null, [11], 3)).resolves.toBe(true);
|
||||
expect(run).toHaveBeenCalledWith([11], 3);
|
||||
expect(broadcastJourneyEvent).not.toHaveBeenCalled();
|
||||
|
||||
svc.scheduleForEntry(5, [{ id: 1, photo_id: 12 }], 3);
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledWith([12], 3));
|
||||
expect(broadcastJourneyEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('JPCAP-005: rows without a trek photo id are skipped, and an empty batch never starts a run', () => {
|
||||
const { svc, run, journeyIdOfEntry } = build(true);
|
||||
|
||||
svc.scheduleForJourney(9, [null, { id: 1 }, { photo_id: 'x' }], 3);
|
||||
svc.scheduleForEntry(4, [], 3);
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(journeyIdOfEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('JPCAP-006: a refresh that could not be sent is logged, never thrown at a detached caller', async () => {
|
||||
const { svc, broadcastJourneyEvent } = build(true);
|
||||
broadcastJourneyEvent.mockImplementation(() => { throw new Error('socket gone'); });
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(svc.fill(9, [11], 3)).resolves.toBe(true);
|
||||
expect(error).toHaveBeenCalledWith('[Journey] capture refresh failed for journey 9:', 'socket gone');
|
||||
});
|
||||
|
||||
it('JPCAP-007: a backfill that rejects is caught too', async () => {
|
||||
const { svc, run, broadcastJourneyEvent } = build(new Error('boom'));
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(svc.fill(9, [11], 3)).resolves.toBe(false);
|
||||
expect(broadcastJourneyEvent).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith('[Journey] capture refresh failed for journey 9:', 'boom');
|
||||
|
||||
// Something thrown that is not an Error is logged as it is.
|
||||
run.mockRejectedValueOnce('gone');
|
||||
await expect(svc.fill(null, [11], 3)).resolves.toBe(false);
|
||||
expect(error).toHaveBeenLastCalledWith('[Journey] capture refresh failed for journey null:', 'gone');
|
||||
});
|
||||
|
||||
it('JPCAP-008: an upload gets the plain backfill and no refresh, since one request is one file', async () => {
|
||||
const { svc, run, schedule, broadcastJourneyEvent, journeyIdOfEntry } = build(true);
|
||||
|
||||
svc.scheduleUpload([{ id: 1, photo_id: 11 }, null, { id: 2 }], 3);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
|
||||
expect(schedule).toHaveBeenCalledWith([11], 3);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(journeyIdOfEntry).not.toHaveBeenCalled();
|
||||
expect(broadcastJourneyEvent).not.toHaveBeenCalled();
|
||||
|
||||
// Nothing with a trek photo id: nothing to schedule.
|
||||
schedule.mockClear();
|
||||
svc.scheduleUpload([{ id: 3 }], 3);
|
||||
expect(schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('JPCAP-009: provider adds from every surface share the user\'s four lookup slots, uploads read their own files, and only the provider adds refresh', async () => {
|
||||
// A gallery add (REST or MCP), an entry add and a device upload (REST or
|
||||
// plugin RPC) landing together: three detached runs on the one backfill.
|
||||
const load = { inFlight: 0, peak: 0 };
|
||||
const getPhotoInfo = vi.fn(async () => {
|
||||
load.inFlight++;
|
||||
load.peak = Math.max(load.peak, load.inFlight);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
load.inFlight--;
|
||||
return { success: true, data: { takenAt: '2026-03-15T08:00:00Z' } };
|
||||
});
|
||||
const recordCaptureMetadata = vi.fn(() => true);
|
||||
// Ids from 30 up are the uploaded files; their EXIF says when they were taken.
|
||||
const resolve = (id: number) =>
|
||||
id >= 30 ? { id, provider: 'local', file_path: `journey/${id}.jpg` } : { id, provider: 'immich' };
|
||||
const withLocalFile = vi.fn(async () => ({ DateTimeOriginal: new Date('2026-03-15T09:00:00Z') }));
|
||||
const backfill = new PhotoCaptureBackfillService(
|
||||
{ getPhotoInfo } as unknown as PhotoResolverService,
|
||||
{ resolve, recordCaptureMetadata } as unknown as TrekPhotosRepository,
|
||||
{ withLocalFile } as unknown as StorageService,
|
||||
);
|
||||
const broadcastJourneyEvent = vi.fn();
|
||||
const svc = new JourneyPhotoCaptureService(
|
||||
backfill,
|
||||
{ broadcastJourneyEvent, journeyIdOfEntry: () => 9 } as unknown as JourneyDomainService,
|
||||
);
|
||||
const rows = (from: number) => Array.from({ length: 5 }, (_, i) => ({ photo_id: from + i }));
|
||||
|
||||
svc.scheduleForJourney(9, rows(10), 3);
|
||||
svc.scheduleForEntry(4, rows(20), 3);
|
||||
svc.scheduleUpload(rows(30), 3);
|
||||
|
||||
await vi.waitFor(() => expect(recordCaptureMetadata).toHaveBeenCalledTimes(15));
|
||||
// Ten provider lookups, never more than four at once; the five uploads never asked a provider.
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(10);
|
||||
expect(load.peak).toBe(4);
|
||||
expect(withLocalFile).toHaveBeenCalledTimes(5);
|
||||
expect(recordCaptureMetadata).toHaveBeenCalledWith(30, { takenAt: '2026-03-15T09:00:00.000Z', lat: null, lng: null });
|
||||
// One event per provider batch, none for the upload.
|
||||
await vi.waitFor(() => expect(broadcastJourneyEvent).toHaveBeenCalledTimes(2));
|
||||
expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import type { Response } from 'express';
|
||||
|
||||
import { JourneyController } from '../../../src/nest/journey/journey.controller';
|
||||
import { journeyThumbName } from '../../../src/nest/memories/thumbnail.service';
|
||||
import type { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
|
||||
import type { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service';
|
||||
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
||||
@@ -28,9 +28,14 @@ const storageStub = {
|
||||
exists: storageExists,
|
||||
sendToResponse: storageSendToResponse,
|
||||
} as unknown as StorageService;
|
||||
// The capture backfill is detached and irrelevant to every case here — it only has
|
||||
// to exist so the constructor can call it.
|
||||
const captureBackfillStub = { schedule: vi.fn(), run: vi.fn() } as unknown as PhotoCaptureBackfillService;
|
||||
// The capture backfill is detached; most cases here only need it to exist so the
|
||||
// constructor can call it. The spies are read by the cases that pin which journey
|
||||
// (or entry) a provider batch is reported against, and that uploads get the
|
||||
// backfill without the refresh (#1587).
|
||||
const scheduleForEntry = vi.fn();
|
||||
const scheduleForJourney = vi.fn();
|
||||
const scheduleUpload = vi.fn();
|
||||
const captureBackfillStub = { scheduleForEntry, scheduleForJourney, scheduleUpload } as unknown as JourneyPhotoCaptureService;
|
||||
|
||||
/**
|
||||
* Build the controller.
|
||||
@@ -93,6 +98,42 @@ describe('JourneyController', () => {
|
||||
expect(thrown(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
|
||||
});
|
||||
|
||||
it('provider-photos hand what they added to the shared capture refresh, by entry or by journey; uploads only backfill (#1587)', async () => {
|
||||
const entryPhoto = { id: 1, photo_id: 11 };
|
||||
const galleryPhoto = { id: 2, photo_id: 12 };
|
||||
const withAdds = svc({
|
||||
addProviderPhoto: vi.fn().mockReturnValue(entryPhoto),
|
||||
addProviderPhotoToGallery: vi.fn().mockReturnValue(galleryPhoto),
|
||||
addPhoto: vi.fn().mockReturnValue(entryPhoto),
|
||||
uploadGalleryPhotos: vi.fn().mockReturnValue([galleryPhoto]),
|
||||
immichAutoUploadEnabled: vi.fn().mockReturnValue(false),
|
||||
} as Partial<JourneyService>);
|
||||
const c = ctl(withAdds);
|
||||
|
||||
c.providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'] });
|
||||
c.providerPhotos(user, '3', { provider: 'immich', asset_id: 'b' });
|
||||
await c.uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
|
||||
expect(scheduleForEntry.mock.calls).toEqual([
|
||||
[3, [entryPhoto], 1],
|
||||
[3, [entryPhoto], 1],
|
||||
]);
|
||||
|
||||
c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a'] });
|
||||
c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'b' });
|
||||
await c.uploadGalleryPhotos(user, '9', [{ filename: 'g.jpg' } as Express.Multer.File]);
|
||||
expect(scheduleForJourney.mock.calls).toEqual([
|
||||
[9, [galleryPhoto], 1],
|
||||
[9, [galleryPhoto], 1],
|
||||
]);
|
||||
|
||||
// The client uploads one file per request, so a refresh per upload would
|
||||
// reload every open client once per photo: uploads backfill and stay quiet.
|
||||
expect(scheduleUpload.mock.calls).toEqual([
|
||||
[[entryPhoto], 1],
|
||||
[[galleryPhoto], 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
|
||||
expect(thrown(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
|
||||
const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 });
|
||||
|
||||
@@ -37,6 +37,39 @@ function build(rows: Row[], info: Record<number, unknown>) {
|
||||
return { svc: new PhotoCaptureBackfillService(resolver, photos, storageStub), recordCaptureMetadata, getPhotoInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* A service whose provider stops answering user 1 until `recover` is called, the
|
||||
* way a NAS that has gone away holds a lookup until its timeout, and answers
|
||||
* every other user at once.
|
||||
*/
|
||||
function buildHung(rows: Row[]) {
|
||||
const nas = { up: false, pending: [] as Array<() => void> };
|
||||
const getPhotoInfo = vi.fn(async (userId: number, _id: number) => {
|
||||
if (userId === 1 && !nas.up) {
|
||||
await new Promise<void>(resolve => {
|
||||
nas.pending.push(resolve);
|
||||
});
|
||||
}
|
||||
return { success: true, data: { takenAt: '2026-03-15T08:00:00Z' } };
|
||||
});
|
||||
const recordCaptureMetadata = vi.fn(() => true);
|
||||
const photos = {
|
||||
resolve: (id: number) => rows.find(r => r.id === id) ?? null,
|
||||
recordCaptureMetadata,
|
||||
} as unknown as TrekPhotosRepository;
|
||||
const svc = new PhotoCaptureBackfillService({ getPhotoInfo } as unknown as PhotoResolverService, photos, storageStub);
|
||||
const recover = () => {
|
||||
nas.up = true;
|
||||
for (const answer of nas.pending.splice(0)) answer();
|
||||
};
|
||||
return { svc, getPhotoInfo, recordCaptureMetadata, recover };
|
||||
}
|
||||
|
||||
/** How many users the service is holding lookup slots for right now. */
|
||||
function usersWithSlots(svc: PhotoCaptureBackfillService): number {
|
||||
return (svc as unknown as { slots: Map<number, unknown> }).slots.size;
|
||||
}
|
||||
|
||||
describe('PhotoCaptureBackfillService', () => {
|
||||
it('CAPTURE-001: records what the provider knows', async () => {
|
||||
const { svc, recordCaptureMetadata } = build(
|
||||
@@ -76,7 +109,7 @@ describe('PhotoCaptureBackfillService', () => {
|
||||
it('CAPTURE-004: a provider that refuses leaves the row alone and does not throw', async () => {
|
||||
const { svc, recordCaptureMetadata } = build([{ id: 7 }], {});
|
||||
|
||||
await expect(svc.run([7], 1)).resolves.toBeUndefined();
|
||||
await expect(svc.run([7], 1)).resolves.toBe(false);
|
||||
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -108,6 +141,172 @@ describe('PhotoCaptureBackfillService', () => {
|
||||
svc.schedule([], 1);
|
||||
expect(getPhotoInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CAPTURE-016: answers whether any row learned something, which is what the journey refresh hangs off', async () => {
|
||||
const { svc, recordCaptureMetadata } = build(
|
||||
[{ id: 7 }, { id: 8 }],
|
||||
{ 7: { takenAt: '2026-03-15T10:20:00Z' }, 8: { takenAt: '2026-03-16T08:00:00Z' } },
|
||||
);
|
||||
// The repository reports no change for 7 (it already knew) and news for 8.
|
||||
recordCaptureMetadata.mockImplementation((id: number) => id === 8);
|
||||
|
||||
await expect(svc.run([7, 8], 1)).resolves.toBe(true);
|
||||
|
||||
recordCaptureMetadata.mockReturnValue(false);
|
||||
await expect(svc.run([7, 8], 1)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
/** A service whose provider takes a moment per photo and counts how many lookups overlap. */
|
||||
function buildSlow(count: number) {
|
||||
const rows = Array.from({ length: count }, (_, i) => ({ id: i + 1 }));
|
||||
const recordCaptureMetadata = vi.fn(() => true);
|
||||
const load = { inFlight: 0, peak: 0 };
|
||||
const getPhotoInfo = vi.fn(async (_userId: number, id: number) => {
|
||||
load.inFlight++;
|
||||
load.peak = Math.max(load.peak, load.inFlight);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
load.inFlight--;
|
||||
return { success: true, data: { takenAt: `2026-03-${String((id % 28) + 1).padStart(2, '0')}T08:00:00Z` } };
|
||||
});
|
||||
const photos = {
|
||||
resolve: (id: number) => rows.find(r => r.id === id) ?? null,
|
||||
recordCaptureMetadata,
|
||||
} as unknown as TrekPhotosRepository;
|
||||
const svc = new PhotoCaptureBackfillService({ getPhotoInfo } as unknown as PhotoResolverService, photos, storageStub);
|
||||
return { svc, rows, recordCaptureMetadata, getPhotoInfo, load };
|
||||
}
|
||||
|
||||
it('CAPTURE-017: one run asks about a few photos at a time instead of one after the other, never more than four', async () => {
|
||||
const { svc, rows, recordCaptureMetadata, getPhotoInfo, load } = buildSlow(10);
|
||||
|
||||
await expect(svc.run(rows.map(r => r.id), 1)).resolves.toBe(true);
|
||||
|
||||
expect(load.peak).toBe(4);
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(10);
|
||||
expect(recordCaptureMetadata.mock.calls.map(c => (c as unknown[])[0]).sort((a, b) => (a as number) - (b as number)))
|
||||
.toEqual(rows.map(r => r.id));
|
||||
});
|
||||
|
||||
it('CAPTURE-018: the four slots are shared by every run of one user, so batches that start together still keep four in flight', async () => {
|
||||
// A 1,500-photo import arrives as three batches of 500, each its own detached
|
||||
// run; a cap per run would have let them open twelve lookups at once.
|
||||
const { svc, rows, getPhotoInfo, load } = buildSlow(15);
|
||||
const ids = rows.map(r => r.id);
|
||||
|
||||
const results = await Promise.all([svc.run(ids.slice(0, 5), 1), svc.run(ids.slice(5, 10), 1), svc.run(ids.slice(10), 1)]);
|
||||
|
||||
expect(results).toEqual([true, true, true]);
|
||||
expect(load.peak).toBe(4);
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(15);
|
||||
|
||||
// Every slot came back: a later run gets the full four again.
|
||||
load.peak = 0;
|
||||
await svc.run(ids.slice(0, 8), 1);
|
||||
expect(load.peak).toBe(4);
|
||||
});
|
||||
|
||||
it('CAPTURE-019: two overlapping runs of one user together never have more than four lookups open', async () => {
|
||||
// Say a gallery import and an entry import landing at the same moment: each
|
||||
// run starts four workers of its own, and only four of the eight may ask.
|
||||
const { svc, rows, getPhotoInfo, load } = buildSlow(12);
|
||||
const ids = rows.map(r => r.id);
|
||||
|
||||
const first = svc.run(ids.slice(0, 6), 1);
|
||||
const second = svc.run(ids.slice(6), 1);
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||
expect(load.peak).toBe(4);
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(12);
|
||||
});
|
||||
|
||||
it('CAPTURE-020: a lookup that throws gives its slot back', async () => {
|
||||
const { svc, rows, getPhotoInfo, load } = buildSlow(8);
|
||||
const ids = rows.map(r => r.id);
|
||||
// The first four lookups take every slot and then throw. Had they kept their
|
||||
// slots, the other four photos would wait for ever and the run never end.
|
||||
const slowAnswer = getPhotoInfo.getMockImplementation()!;
|
||||
getPhotoInfo.mockImplementation(async (userId: number, id: number) => {
|
||||
if (id > 4) return slowAnswer(userId, id);
|
||||
load.inFlight++;
|
||||
load.peak = Math.max(load.peak, load.inFlight);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
load.inFlight--;
|
||||
throw new Error('NAS gone');
|
||||
});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(svc.run(ids, 1)).resolves.toBe(true);
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(8);
|
||||
expect(error).toHaveBeenCalledWith('[Photos] capture backfill failed for 1:', 'NAS gone');
|
||||
|
||||
// All four slots are free again for the next import.
|
||||
load.peak = 0;
|
||||
getPhotoInfo.mockImplementation(slowAnswer);
|
||||
await svc.run(ids, 1);
|
||||
expect(load.peak).toBe(4);
|
||||
expect(usersWithSlots(svc)).toBe(0);
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('CAPTURE-021: a provider that knows only the place records the place, and the time stays unknown', async () => {
|
||||
const { svc, recordCaptureMetadata } = build([{ id: 7 }], { 7: { lat: 48.8584, lng: 2.2945 } });
|
||||
|
||||
await svc.run([7], 1);
|
||||
|
||||
expect(recordCaptureMetadata).toHaveBeenCalledWith(7, { takenAt: null, lat: 48.8584, lng: 2.2945 });
|
||||
});
|
||||
|
||||
it('CAPTURE-022: a lookup that throws something other than an Error is logged as it is', async () => {
|
||||
const { svc, getPhotoInfo } = build([{ id: 7 }], {});
|
||||
getPhotoInfo.mockImplementation(async () => { throw 'socket hang up'; });
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(svc.run([7], 1)).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith('[Photos] capture backfill failed for 7:', 'socket hang up');
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('CAPTURE-023: a NAS that stopped answering one user holds up nobody else', async () => {
|
||||
const { svc, getPhotoInfo, recordCaptureMetadata, recover } = buildHung(
|
||||
Array.from({ length: 10 }, (_, i) => ({ id: i + 1 })),
|
||||
);
|
||||
|
||||
// User 1 imports eight photos: the first four lookups take every slot of
|
||||
// theirs and hang, the other four wait behind them.
|
||||
const stuck = svc.run([1, 2, 3, 4, 5, 6, 7, 8], 1);
|
||||
await vi.waitFor(() => expect(getPhotoInfo).toHaveBeenCalledTimes(4));
|
||||
|
||||
// User 2's import is not queued behind them.
|
||||
await expect(svc.run([9, 10], 2)).resolves.toBe(true);
|
||||
expect(getPhotoInfo).toHaveBeenCalledWith(2, 9);
|
||||
expect(getPhotoInfo).toHaveBeenCalledWith(2, 10);
|
||||
expect(recordCaptureMetadata).toHaveBeenCalledWith(10, { takenAt: '2026-03-15T08:00:00Z', lat: null, lng: null });
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(6);
|
||||
expect(usersWithSlots(svc)).toBe(1);
|
||||
|
||||
// Once the NAS is back, user 1's run finishes and leaves no slots behind.
|
||||
recover();
|
||||
await expect(stuck).resolves.toBe(true);
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(10);
|
||||
expect(usersWithSlots(svc)).toBe(0);
|
||||
});
|
||||
|
||||
it('CAPTURE-024: every user has four slots of their own, so two importers may have eight lookups open', async () => {
|
||||
const { svc, rows, getPhotoInfo, load } = buildSlow(12);
|
||||
const ids = rows.map(r => r.id);
|
||||
|
||||
await expect(Promise.all([svc.run(ids.slice(0, 6), 1), svc.run(ids.slice(6), 2)])).resolves.toEqual([true, true]);
|
||||
|
||||
expect(load.peak).toBe(8);
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(12);
|
||||
expect(usersWithSlots(svc)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PhotoCaptureBackfillService — local files', () => {
|
||||
@@ -181,7 +380,7 @@ describe('PhotoCaptureBackfillService — local files', () => {
|
||||
{ id: 7, provider: 'local', file_path: 'journey/a.jpg' },
|
||||
]);
|
||||
|
||||
await expect(svc.run([7], 1)).resolves.toBeUndefined();
|
||||
await expect(svc.run([7], 1)).resolves.toBe(false);
|
||||
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -219,7 +418,7 @@ describe('PhotoCaptureBackfillService — local files', () => {
|
||||
} as unknown as TrekPhotosRepository;
|
||||
const svc = new PhotoCaptureBackfillService({} as PhotoResolverService, photos, storageStub);
|
||||
|
||||
await expect(svc.run([7], 1)).resolves.toBeUndefined();
|
||||
await expect(svc.run([7], 1)).resolves.toBe(false);
|
||||
expect(recordCaptureMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -232,4 +431,25 @@ describe('PhotoCaptureBackfillService — local files', () => {
|
||||
svc.schedule([7], 1);
|
||||
await vi.waitFor(() => expect(recordCaptureMetadata).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('CAPTURE-025: a device upload reads its EXIF while the same user\'s provider lookups hang', async () => {
|
||||
vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: new Date('2026-03-15T10:20:00Z') });
|
||||
const { svc, getPhotoInfo, recordCaptureMetadata, recover } = buildHung([
|
||||
{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 },
|
||||
{ id: 5, provider: 'local', file_path: 'journey/upload.jpg' },
|
||||
]);
|
||||
|
||||
// The user's own import has every lookup slot taken by a NAS that hangs.
|
||||
const stuck = svc.run([1, 2, 3, 4], 1);
|
||||
await vi.waitFor(() => expect(getPhotoInfo).toHaveBeenCalledTimes(4));
|
||||
|
||||
// A file on TREK's own storage needs no slot, so the upload is not queued.
|
||||
await expect(svc.run([5], 1)).resolves.toBe(true);
|
||||
expect(storageStub.withLocalFile).toHaveBeenCalledWith('journey', 'upload.jpg', expect.any(Function));
|
||||
expect(recordCaptureMetadata).toHaveBeenCalledWith(5, { takenAt: '2026-03-15T10:20:00.000Z', lat: null, lng: null });
|
||||
expect(getPhotoInfo).toHaveBeenCalledTimes(4);
|
||||
|
||||
recover();
|
||||
await expect(stuck).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,4 +92,17 @@ describe('TrekPhotosRepository.recordCaptureMetadata', () => {
|
||||
|
||||
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
|
||||
});
|
||||
|
||||
it('TREKPHOTO-006: says whether the row learned anything, so a refresh is only sent for news', () => {
|
||||
const id = makePhoto();
|
||||
|
||||
expect(repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' })).toBe(true);
|
||||
// Same answer again: every COALESCE keeps what is there.
|
||||
expect(repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z' })).toBe(false);
|
||||
// Half a pair is dropped, so there is nothing new in it either.
|
||||
expect(repo.recordCaptureMetadata(id, { lat: 48.8584, lng: null })).toBe(false);
|
||||
expect(repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 })).toBe(true);
|
||||
expect(repo.recordCaptureMetadata(id, {})).toBe(false);
|
||||
expect(repo.recordCaptureMetadata(999999, { takenAt: '2026-03-15T10:20:00Z' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ function build(addonOn: boolean) {
|
||||
new JournalRpc(spyService(calls, 'journey'), guards,
|
||||
{ put: async () => undefined, delete: async () => undefined } as never,
|
||||
{ get: () => '*' } as never,
|
||||
{ schedule: () => undefined } as never,
|
||||
{ scheduleUpload: () => undefined } as never,
|
||||
{ prepare: () => ({ get: () => ({ email: 'u@example.test' }) }) } as never),
|
||||
new CollectionsRpc(spyService(calls, 'collections'), guards),
|
||||
new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')),
|
||||
|
||||
@@ -31,7 +31,7 @@ function build(overrides: {
|
||||
// Typed parameters so the assertions below can index mock.calls.
|
||||
const put = vi.fn(async (_category: string, _filename: string, _body?: unknown, _opts?: unknown) => undefined);
|
||||
const del = vi.fn(async (_category: string, _filename: string) => undefined);
|
||||
const schedule = vi.fn();
|
||||
const scheduleUpload = vi.fn();
|
||||
const addPhoto = vi.fn(overrides.addPhoto === undefined
|
||||
? () => ({ id: 5, photo_id: 42 })
|
||||
: () => overrides.addPhoto);
|
||||
@@ -43,10 +43,10 @@ function build(overrides: {
|
||||
{ requireAddon: vi.fn() } as never,
|
||||
{ put, delete: del } as never,
|
||||
{ get: () => overrides.allowed ?? '*' } as never,
|
||||
{ schedule } as never,
|
||||
{ scheduleUpload } as never,
|
||||
{ prepare: () => ({ get: () => ({ email: overrides.email ?? 'user@example.test' }) }) } as never,
|
||||
);
|
||||
return { rpc, put, del, schedule, addPhoto };
|
||||
return { rpc, put, del, scheduleUpload, addPhoto };
|
||||
}
|
||||
|
||||
const input = (over: Record<string, unknown> = {}) => ({ name: 'photo.jpg', content_base64: PNG, ...over });
|
||||
@@ -57,7 +57,7 @@ beforeEach(() => {
|
||||
|
||||
describe('journal.addEntryPhoto', () => {
|
||||
it('JPHOTO-001: stores the bytes under a name of its own, then links the photo', async () => {
|
||||
const { rpc, put, schedule, addPhoto } = build();
|
||||
const { rpc, put, scheduleUpload, addPhoto } = build();
|
||||
|
||||
const photo = await rpc.addEntryPhoto({ entryId: 3, input: input({ caption: 'Tokyo' }) }, ACTOR);
|
||||
|
||||
@@ -69,7 +69,9 @@ describe('journal.addEntryPhoto', () => {
|
||||
expect(category).toBe('journey');
|
||||
expect(filename).toMatch(/^[0-9a-f-]{36}\.jpg$/);
|
||||
expect(addPhoto).toHaveBeenCalledWith(3, 7, `journey/${filename}`, undefined, 'Tokyo');
|
||||
expect(schedule).toHaveBeenCalledWith([42], 7);
|
||||
// An upload's capture time is backfilled like the REST upload's, without the
|
||||
// journey refresh: one photo per call would be one reload per photo (#1587).
|
||||
expect(scheduleUpload).toHaveBeenCalledWith([{ id: 5, photo_id: 42 }], 7);
|
||||
});
|
||||
|
||||
it('JPHOTO-002: refuses an entry the acting user cannot edit, and removes the object it just wrote', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
PROVIDER_SELECT_ALL_MAX_PAGES,
|
||||
addTripPhotosSchema,
|
||||
createAlbumLinkSchema,
|
||||
immichSearchSchema,
|
||||
@@ -71,6 +72,12 @@ describe('immich contracts', () => {
|
||||
expect(immichSearchSchema.safeParse({ utc_offset_minutes: '-480' }).success).toBe(true);
|
||||
expect(immichSearchSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('pins how deep the picker pages a search, which the server scan ceiling is sized from (#1587)', () => {
|
||||
// Page 251 of 200 ends at photo 50,200. Raising it deepens the server's scan
|
||||
// ceiling with it, since that ceiling is derived from this number.
|
||||
expect(PROVIDER_SELECT_ALL_MAX_PAGES).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('synology contracts', () => {
|
||||
|
||||
@@ -75,6 +75,20 @@ export const immichSearchSchema = z.looseObject({
|
||||
utc_offset_minutes: utcOffsetMinutes,
|
||||
});
|
||||
|
||||
/**
|
||||
* How far the journey photo picker pages a date-bounded search (#1587): its
|
||||
* first page and at most this many after it, so it never asks for a page past
|
||||
* PROVIDER_SELECT_ALL_MAX_PAGES + 1. At the picker's 200 a page that is 50,200
|
||||
* photos.
|
||||
*
|
||||
* An absolute page rather than a count per run: whether "Select all" loads the
|
||||
* pages or they are scrolled into view, and however often it is pressed, page
|
||||
* 251 is the last. Shared because both ends size against it: the picker stops
|
||||
* there, and the Immich search keeps its absolute scan ceiling deep enough that
|
||||
* this last page is still answered in full.
|
||||
*/
|
||||
export const PROVIDER_SELECT_ALL_MAX_PAGES = 250;
|
||||
|
||||
// ── Synology ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const synologySettingsSchema = z.looseObject({
|
||||
|
||||
@@ -123,6 +123,7 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
|
||||
},
|
||||
'journey:book:cursor': { journeyId: 7, socketId: 3, userId: 2, spreadIndex: 0, x: 105.5, y: 60 },
|
||||
'journey:contributor:changed': { journeyId: 3, targetUserId: 2, role: 'editor' },
|
||||
'journey:photos:updated': { journeyId: 3 },
|
||||
'import:progress': { jobId: 'j1', tripId: 1, status: 'running', done: 1, total: 3, fileName: 'a.pdf' },
|
||||
'import:done': { jobId: 'j1', tripId: 1, result: { items: [] } },
|
||||
'import:error': { jobId: 'j1', tripId: 1, message: 'boom' },
|
||||
@@ -145,14 +146,16 @@ const DRIFT_VARIANTS: Partial<Record<TrekWsEventName, Record<string, unknown>[]>
|
||||
};
|
||||
|
||||
describe('@trek/shared realtime event registry', () => {
|
||||
it('WSEVT-REG-001: pins the authoritative inventory counts (74 trip + 32 user = 106)', () => {
|
||||
it('WSEVT-REG-001: pins the authoritative inventory counts (74 trip + 33 user = 107)', () => {
|
||||
// 67th to 69th trip event: the three collab:link:* a shared link emits.
|
||||
// 70th and 71st: the road trip's vias and tracks, which used to be written silently.
|
||||
// 74th: docsync:changed, so a sync run that moved documents refreshes the
|
||||
// panel without every member polling for it.
|
||||
// 33rd user event: journey:photos:updated, so the gallery re-sorts once the
|
||||
// capture times of an import have landed (#1587).
|
||||
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(74);
|
||||
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(32);
|
||||
expect(TREK_WS_EVENT_NAMES).toHaveLength(106);
|
||||
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(33);
|
||||
expect(TREK_WS_EVENT_NAMES).toHaveLength(107);
|
||||
});
|
||||
|
||||
it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => {
|
||||
|
||||
@@ -319,6 +319,19 @@ export const TREK_WS_EVENTS = {
|
||||
scope: 'user',
|
||||
payload: z.object({ journeyId: id, targetUserId: id, role: z.unknown() }),
|
||||
},
|
||||
/*
|
||||
* Photos on the journey learned when (or where) they were taken (#1587).
|
||||
*
|
||||
* Sent once per batch of provider photos (Immich, Synology) added, after the
|
||||
* detached capture-time backfill wrote something, and to everyone on the
|
||||
* journey including whoever added them: the add answered before the backfill
|
||||
* ran, so the importer's own reload still had the new photos at the end of the
|
||||
* gallery. Content-free, the gallery order is the server's to work out.
|
||||
*
|
||||
* Never for a device upload: those go one file per request, so an event per
|
||||
* request would reload every open client once per uploaded photo.
|
||||
*/
|
||||
'journey:photos:updated': { scope: 'user', payload: z.object({ journeyId: id }) },
|
||||
|
||||
/*
|
||||
* The Studio book was saved (#1973).
|
||||
|
||||
@@ -63,6 +63,10 @@ The entry editor includes an **External photos** tab for connected Immich and Sy
|
||||
|
||||
External selections are queued with the other editor changes and saved only when you click **Save**. If a provider is unavailable or its metadata has no GPS coordinates, Journey falls back to the normal date-based photo list.
|
||||
|
||||
The same photo picker opens from the journey gallery, on desktop and on a phone. **Select all** takes every photo of the search, not only the ones scrolled into view: on the day, **Trip Period** and **Date Range** tabs it first loads the pages still missing, with a spinner on the button while it does, and closing the picker or switching tab cancels it. **All Photos** is your whole library, so there Select all takes only the photos loaded so far, and a count such as **(200+)** says there are more further down. Select all adds to what you already picked on another tab or in an album, and **Deselect all** takes back only the photos in view. **Add** waits until Select all has finished loading, so it never sends only part of the selection. The picker loads up to 50,200 photos of one date search, by Select all or by scrolling, far more than a trip usually holds; past that the count keeps its **+**, and a narrower **Date Range** reaches the rest. A large selection is sent in several requests, and if one of them fails, the photos that did arrive show up in the journey straight away while an error says the rest did not; adding the same selection again skips the photos that are already there.
|
||||
|
||||
Imported photos are added oldest first, so an entry lists a bulk import in the order the photos were taken, and you can still drag them into another order afterwards. The gallery sorts by capture time, which TREK asks Immich or Synology Photos for right after the import; once those times arrive the gallery re-sorts by itself on every device that has the journey open.
|
||||
|
||||
## Mobile timeline
|
||||
|
||||
On a phone, entries sit in a horizontal carousel over the map, one photo card per entry. The entry's first photo is the card (a pin placeholder when it has none), with the title and place on it, the country flag and the date in the corners, and the mood and weather icons next to the date. A suggestion from a linked trip shows a plus mark and its title in italics. The day an entry belongs to is carried by the colour of the card's edge, the same colour its map marker uses; there is no day number on the card. Tap the centred card to open the entry in a sheet.
|
||||
|
||||
Reference in New Issue
Block a user