Skip to content

Commit d17d876

Browse files
committed
fix image cdn, added better error handling
1 parent af256f6 commit d17d876

9 files changed

Lines changed: 226 additions & 53 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ The CLI config file is at `~/.config/chat-to-map/config.json`:
359359
## Media Library
360360

361361
**Local path:** `/Users/ndbroadbent/code/chat_to_map_worktrees/media-library/media_library/images/`
362-
**CDN:** `https://media.chattomap.com/images/` (synced via rclone to R2)
362+
**CDN:** `https://media.chattomap.com/images/` for image assets, with the index at
363+
`https://media.chattomap.com/index.json.gz` (synced via rclone to R2)
363364

364365
```
365366
images/

src/images/index.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Images Module Tests
33
*/
44

5-
import { beforeEach, describe, expect, it } from 'vitest'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import type { CachedResponse, ResponseCache } from '../caching/types'
77
import { createGeocodedActivity } from '../test-support'
88
import type { GeocodedActivity } from '../types/place-lookup'
@@ -33,6 +33,7 @@ function createMockActivity(overrides: Partial<GeocodedActivity> = {}): Geocoded
3333

3434
describe('Images Module', () => {
3535
beforeEach(() => {
36+
vi.restoreAllMocks()
3637
clearMediaIndexCache()
3738
})
3839

@@ -251,5 +252,33 @@ describe('Images Module', () => {
251252

252253
expect(results.size).toBe(0)
253254
})
255+
256+
it('caches a missing media index so preview image fetches do not refetch it per activity', async () => {
257+
const cache = createMockCache()
258+
const activities = [
259+
createMockActivity({ activity: 'Coffee' }),
260+
createMockActivity({ activity: 'Dinner' }),
261+
createMockActivity({ activity: 'Walk' })
262+
]
263+
const config: ImageFetchConfig = {
264+
skipGooglePlaces: true,
265+
skipPexels: true,
266+
skipPixabay: true
267+
}
268+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
269+
ok: false,
270+
status: 404
271+
} as Response)
272+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
273+
274+
const results = await fetchImagesForActivities(activities, config, cache)
275+
276+
expect(results.size).toBe(3)
277+
expect(results.get(activities[0]?.activityId ?? '')).toBeNull()
278+
expect(results.get(activities[1]?.activityId ?? '')).toBeNull()
279+
expect(results.get(activities[2]?.activityId ?? '')).toBeNull()
280+
expect(fetchSpy).toHaveBeenCalledTimes(1)
281+
expect(warnSpy).not.toHaveBeenCalled()
282+
})
254283
})
255284
})

src/images/index.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,10 @@ export {
6969
type LicenseCheckResult
7070
} from './wikipedia-license'
7171

72-
/** Cached media index - loaded once per session */
73-
let cachedMediaIndex: MediaIndex | null = null
72+
/** Cached media index - `undefined` means not loaded yet, `null` means unavailable. */
73+
let cachedMediaIndex: MediaIndex | null | undefined
7474
let mediaIndexPath: string | null = null
75+
let mediaIndexPromise: Promise<MediaIndex | null> | null = null
7576

7677
/**
7778
* Fetch an image for a single activity.
@@ -335,26 +336,39 @@ async function tryMediaLibraryCategoryFallback(
335336
async function getMediaIndex(config: ImageFetchConfig): Promise<MediaIndex | null> {
336337
const currentPath = config.mediaLibraryPath ?? null
337338

338-
// Return cached index if path matches
339-
if (cachedMediaIndex !== null && mediaIndexPath === currentPath) {
339+
if (mediaIndexPath !== currentPath) {
340+
cachedMediaIndex = undefined
341+
mediaIndexPath = currentPath
342+
mediaIndexPromise = null
343+
}
344+
345+
if (cachedMediaIndex !== undefined) {
340346
return cachedMediaIndex
341347
}
342348

343-
// Load and cache
344-
cachedMediaIndex = await loadMediaIndex({
345-
localPath: config.mediaLibraryPath
346-
})
347-
mediaIndexPath = currentPath
349+
if (!mediaIndexPromise) {
350+
mediaIndexPromise = loadMediaIndex({
351+
localPath: config.mediaLibraryPath
352+
}).then((index) => {
353+
cachedMediaIndex = index
354+
return index
355+
})
356+
}
348357

349-
return cachedMediaIndex
358+
try {
359+
return await mediaIndexPromise
360+
} finally {
361+
mediaIndexPromise = null
362+
}
350363
}
351364

352365
/**
353366
* Clear cached media index (useful for testing).
354367
*/
355368
export function clearMediaIndexCache(): void {
356-
cachedMediaIndex = null
369+
cachedMediaIndex = undefined
357370
mediaIndexPath = null
371+
mediaIndexPromise = null
358372
}
359373

360374
/**

src/images/media-index.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
22
import {
33
buildImageUrl,
44
findActionFallbackImage,
55
findCategoryFallbackImage,
66
findCountryImage,
77
findObjectImage,
8+
loadMediaIndex,
89
MEDIA_CDN_URL,
10+
MEDIA_INDEX_URL,
911
type MediaIndex,
1012
resolveEntry
1113
} from './media-index'
@@ -56,6 +58,26 @@ const TEST_INDEX: MediaIndex = {
5658
}
5759
}
5860

61+
beforeEach(() => {
62+
vi.restoreAllMocks()
63+
})
64+
65+
describe('loadMediaIndex', () => {
66+
it('returns null without warning when the CDN index is missing', async () => {
67+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
68+
ok: false,
69+
status: 404
70+
} as Response)
71+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
72+
73+
await expect(loadMediaIndex()).resolves.toBeNull()
74+
75+
expect(fetchSpy).toHaveBeenCalledWith(MEDIA_INDEX_URL, undefined)
76+
expect(fetchSpy).toHaveBeenCalledTimes(1)
77+
expect(warnSpy).not.toHaveBeenCalled()
78+
})
79+
})
80+
5981
describe('resolveEntry', () => {
6082
it('resolves direct hash', () => {
6183
const result = resolveEntry('abc123', 'objects', 'swimming')

src/images/media-index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { httpFetch } from '../http'
1515

1616
/** CDN base URL for media library */
1717
export const MEDIA_CDN_URL = 'https://media.chattomap.com/images'
18+
/** CDN URL for the gzipped media index */
19+
export const MEDIA_INDEX_URL = 'https://media.chattomap.com/index.json.gz'
1820

1921
/** Available image sizes in the media library */
2022
export const IMAGE_SIZES = [1400, 700, 400, 128] as const
@@ -115,16 +117,30 @@ export async function loadMediaIndex(options?: MediaIndexOptions): Promise<Media
115117
}
116118
return await loadCdnIndex()
117119
} catch (error) {
120+
if (isMissingMediaIndexError(error)) {
121+
return null
122+
}
118123
console.warn('Failed to load media index:', error)
119124
return null
120125
}
121126
}
122127

128+
function isMissingMediaIndexError(error: unknown): boolean {
129+
if (!(error instanceof Error)) {
130+
return false
131+
}
132+
133+
return (
134+
error.message === 'Failed to fetch media index: 404' ||
135+
error.message.startsWith('Media index not found at ')
136+
)
137+
}
138+
123139
/**
124140
* Load index from CDN (gzipped).
125141
*/
126142
async function loadCdnIndex(): Promise<MediaIndex> {
127-
const response = await httpFetch(`${MEDIA_CDN_URL}/index.json.gz`)
143+
const response = await httpFetch(MEDIA_INDEX_URL)
128144
if (!response.ok) {
129145
throw new Error(`Failed to fetch media index: ${response.status}`)
130146
}

src/place-lookup/coordinates.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { GeocodedActivity } from '../types'
2+
3+
/**
4+
* Count activities with coordinates.
5+
*/
6+
export function countWithCoordinates(activities: readonly GeocodedActivity[]): number {
7+
return activities.filter((a) => a.latitude !== undefined && a.longitude !== undefined).length
8+
}
9+
10+
/**
11+
* Filter to only activities with coordinates.
12+
*/
13+
export function filterWithCoordinates(activities: readonly GeocodedActivity[]): GeocodedActivity[] {
14+
return activities.filter(
15+
(a): a is GeocodedActivity & { latitude: number; longitude: number } =>
16+
a.latitude !== undefined && a.longitude !== undefined
17+
)
18+
}
19+
20+
/**
21+
* Calculate the center point of activities with coordinates.
22+
*/
23+
export function calculateCenter(
24+
activities: readonly GeocodedActivity[]
25+
): { lat: number; lng: number } | null {
26+
const withCoords = filterWithCoordinates(activities)
27+
28+
if (withCoords.length === 0) {
29+
return null
30+
}
31+
32+
const sumLat = withCoords.reduce((sum, a) => sum + (a.latitude as number), 0)
33+
const sumLng = withCoords.reduce((sum, a) => sum + (a.longitude as number), 0)
34+
35+
return {
36+
lat: sumLat / withCoords.length,
37+
lng: sumLng / withCoords.length
38+
}
39+
}

src/place-lookup/index.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,46 @@ describe('Geocoder Module', () => {
317317

318318
expect(results.activities[0]?.latitude).toBeCloseTo(48.8, 1)
319319
expect(results.activities[0]?.placeLookupSource).toBe('places_api')
320+
expect(results.activities[0]?.matchedPlaceName).toBe('Eiffel Tower')
321+
})
322+
323+
it('preserves the matched Google place name for venue lookups', async () => {
324+
mockFetch.mockResolvedValue({
325+
ok: true,
326+
json: async () =>
327+
createPlacesResponse(
328+
-36.8734,
329+
174.7606,
330+
'193 Symonds Street, Eden Terrace, Auckland 1010, New Zealand',
331+
'Kazuya'
332+
)
333+
})
334+
335+
const activity = createTestActivity({
336+
activity: 'Dinner at Kazuya',
337+
category: 'food',
338+
score: 0.9,
339+
placeQuery: 'Kazuya',
340+
city: 'Auckland',
341+
messages: [
342+
{
343+
id: 1,
344+
sender: 'Test User',
345+
timestamp: new Date('2025-01-15T10:30:00Z'),
346+
message: 'Dinner at Kazuya'
347+
}
348+
]
349+
})
350+
351+
const results = await lookupActivityPlaces([activity], {
352+
apiKey: 'test-key'
353+
})
354+
355+
expect(results.activities[0]?.placeLookupSource).toBe('places_api')
356+
expect(results.activities[0]?.matchedPlaceName).toBe('Kazuya')
357+
expect(results.activities[0]?.formattedAddress).toBe(
358+
'193 Symonds Street, Eden Terrace, Auckland 1010, New Zealand'
359+
)
320360
})
321361

322362
it('returns activity without coordinates when all geocoding fails', async () => {
@@ -337,6 +377,33 @@ describe('Geocoder Module', () => {
337377
expect(results.activities[0]?.longitude).toBeUndefined()
338378
})
339379

380+
it('logs provider errors when Google denies the lookup', async () => {
381+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
382+
383+
mockFetch.mockResolvedValue({
384+
ok: true,
385+
json: async () => ({
386+
status: 'REQUEST_DENIED',
387+
results: [],
388+
error_message: 'Billing disabled'
389+
})
390+
})
391+
392+
const activity = createActivity(1, 'Rotoroa Island', 'Auckland')
393+
394+
const results = await lookupActivityPlaces([activity], {
395+
apiKey: 'test-key'
396+
})
397+
398+
expect(results.activities[0]?.latitude).toBeUndefined()
399+
expect(warnSpy).toHaveBeenCalledWith(
400+
'[place-lookup] geocoding failed for "Auckland": Billing disabled'
401+
)
402+
expect(warnSpy).toHaveBeenCalledWith(
403+
'[place-lookup] activity fallback search failed for "Rotoroa Island": Billing disabled'
404+
)
405+
})
406+
340407
it('handles activities without location', async () => {
341408
const activity = createActivity(1, 'Some activity')
342409

0 commit comments

Comments
 (0)