From b5b1d32b31c56e3e62283ba9e69c79a4d8e267df Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 20:49:38 +0200 Subject: [PATCH 1/2] feat(photos): add 1h disk cache for remote thumbnails and keep tabs mounted Closes #686 - Add trekPhotoCache service: SHA1-keyed disk cache under uploads/photos/trek/, 1h TTL, in-flight dedup map to prevent stampedes on concurrent requests - Add migration 108: trek_photo_cache_meta table - Hook cache into streamPhoto for Immich/Synology thumbnail path; originals bypass cache - Add fetchImmichThumbnailBytes / fetchSynologyThumbnailBytes returning Buffer instead of piping, used by the cache layer - Add scheduler entry (every 2h + startup sweep) to evict expired disk files and DB rows via sweepExpired() - Client: convert journey tab conditional-mount to hidden-toggle so img elements stay in DOM across tab switches, preventing redundant thumbnail requests on rapid tab changes - Expose invalidateSize() on JourneyMapHandle; call it on map tab activation to fix Leaflet rendering in previously-hidden container --- client/src/components/Journey/JourneyMap.tsx | 7 +- client/src/pages/JourneyDetailPage.tsx | 34 ++++--- server/src/db/migrations.ts | 9 ++ server/src/index.ts | 1 + server/src/scheduler.ts | 26 +++++- server/src/services/memories/immichService.ts | 24 +++++ .../services/memories/photoResolverService.ts | 51 ++++++++++- .../src/services/memories/synologyService.ts | 41 +++++++++ .../src/services/memories/trekPhotoCache.ts | 91 +++++++++++++++++++ 9 files changed, 268 insertions(+), 16 deletions(-) create mode 100644 server/src/services/memories/trekPhotoCache.ts diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 4363b4e3..0f9a5a36 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -14,6 +14,7 @@ export interface MapMarkerItem { export interface JourneyMapHandle { highlightMarker: (id: string | null) => void focusMarker: (id: string) => void + invalidateSize: () => void } interface MapEntry { @@ -151,7 +152,11 @@ const JourneyMap = forwardRef(function JourneyMap( } }, []) - useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), []) + const invalidateSize = useCallback(() => { + try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ } + }, []) + + useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), []) useEffect(() => { if (!containerRef.current) return diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 398f74cd..5b8f94cb 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -164,6 +164,12 @@ export default function JourneyDetailPage() { setActiveLocationId(id) }, []) + useEffect(() => { + if (view === 'map') { + requestAnimationFrame(() => fullMapRef.current?.invalidateSize()) + } + }, [view]) + const mapEntries = useMemo( () => (current?.entries || []).filter(e => e.location_lat && e.location_lng), [current?.entries] @@ -413,8 +419,8 @@ export default function JourneyDetailPage() { {/* Timeline (desktop only — mobile uses fullscreen combined view above) */} - {!isMobile && view === 'timeline' && ( -
+ {!isMobile && ( +
{sortedDates.length === 0 && (
@@ -469,7 +475,7 @@ export default function JourneyDetailPage() { )} {/* Gallery View */} - {view === 'gallery' && ( +
setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} onRefresh={() => loadJourney(Number(id))} /> - )} +
{/* Full Map View (desktop only — mobile uses combined view) */} - {!isMobile && view === 'map' &&
} + {!isMobile && ( +
+ +
+ )}
{/* Right sidebar — hidden on mobile */} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index d4786fa4..a59b5f06 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1673,6 +1673,15 @@ function runMigrations(db: Database.Database): void { ) `); }}, + // Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology) + () => db.exec(` + CREATE TABLE IF NOT EXISTS trek_photo_cache_meta ( + cache_key TEXT PRIMARY KEY, + content_type TEXT NOT NULL DEFAULT 'image/jpeg', + fetched_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at); + `), ]; if (currentVersion < migrations.length) { diff --git a/server/src/index.ts b/server/src/index.ts index 91417455..c84a0839 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -49,6 +49,7 @@ const server = app.listen(PORT, () => { scheduler.startVersionCheck(); scheduler.startDemoReset(); scheduler.startIdempotencyCleanup(); + scheduler.startTrekPhotoCacheCleanup(); const { startTokenCleanup } = require('./services/ephemeralTokens'); startTokenCleanup(); import('./websocket').then(({ setupWebSocket }) => { diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index f04b0df7..b11dc11c 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -248,12 +248,36 @@ function startIdempotencyCleanup(): void { }, { timezone: tz }); } +// Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL +let trekPhotoCacheTask: ScheduledTask | null = null; + +function startTrekPhotoCacheCleanup(): void { + if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; } + + // Run once immediately on startup to evict any entries left over from a previous run + try { + const { sweepExpired } = require('./services/memories/trekPhotoCache'); + sweepExpired(); + } catch { /* cache dir may not exist yet — harmless */ } + + trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => { + try { + const { sweepExpired } = require('./services/memories/trekPhotoCache'); + sweepExpired(); + } catch (err: unknown) { + const { logError: le } = require('./services/auditLog'); + le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`); + } + }); +} + function stop(): void { if (currentTask) { currentTask.stop(); currentTask = null; } if (demoTask) { demoTask.stop(); demoTask = null; } if (reminderTask) { reminderTask.stop(); reminderTask = null; } if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; } if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; } + if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; } } -export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS }; +export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS }; diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index dd212d34..34c37bca 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -230,6 +230,30 @@ export async function getAssetInfo( } } +export async function fetchImmichThumbnailBytes( + userId: number, + assetId: string, + ownerUserId?: number +): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { + const effectiveUserId = ownerUserId ?? userId; + const creds = getImmichCredentials(effectiveUserId); + if (!creds) return { error: 'Not found', status: 404 }; + + const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`; + try { + const resp = await safeFetch(url, { + headers: { 'x-api-key': creds.immich_api_key }, + signal: AbortSignal.timeout(10000) as any, + }); + if (!resp.ok) return { error: 'Upstream error', status: resp.status }; + const contentType = resp.headers.get('content-type') || 'image/jpeg'; + const bytes = Buffer.from(await resp.arrayBuffer()); + return { bytes, contentType }; + } catch { + return { error: 'Proxy error', status: 502 }; + } +} + export async function streamImmichAsset( response: Response, userId: number, diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts index 3af080e5..82d07243 100644 --- a/server/src/services/memories/photoResolverService.ts +++ b/server/src/services/memories/photoResolverService.ts @@ -3,11 +3,12 @@ import path from 'path'; import fs from 'fs'; import { db } from '../../db/database'; import type { TrekPhoto } from '../../types'; -import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService'; -import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService'; +import { streamImmichAsset, fetchImmichThumbnailBytes, getAssetInfo as getImmichAssetInfo } from './immichService'; +import { streamSynologyAsset, fetchSynologyThumbnailBytes, getSynologyAssetInfo } from './synologyService'; import type { ServiceResult, AssetInfo } from './helpersService'; import { fail, success } from './helpersService'; import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; +import * as photoCache from './trekPhotoCache'; // ── Lookup / Register ──────────────────────────────────────────────────── @@ -57,6 +58,36 @@ export function resolveTrekPhoto(photoId: number): TrekPhoto | null { // ── Streaming ──────────────────────────────────────────────────────────── +async function streamCachedThumbnail( + res: Response, + photo: TrekPhoto, + fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>, + fallback: () => Promise, +): Promise { + const key = photoCache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!); + + if (photoCache.serveFresh(res, key)) return; + + const existing = photoCache.getInFlight(key); + if (existing) { + const bytes = await existing; + if (bytes && photoCache.serveFresh(res, key)) return; + await fallback(); + return; + } + + const promise = fetchBytes().then(async result => { + if ('error' in result) return null; + await photoCache.put(key, result.bytes, result.contentType); + return result.bytes; + }); + photoCache.setInFlight(key, promise); + + const bytes = await promise; + if (bytes && photoCache.serveFresh(res, key)) return; + await fallback(); +} + export async function streamPhoto( res: Response, userId: number, @@ -84,11 +115,27 @@ export async function streamPhoto( return; } case 'immich': { + if (kind === 'thumbnail') { + await streamCachedThumbnail( + res, photo, + () => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!), + () => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!), + ); + return; + } await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!); return; } case 'synologyphotos': { const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined; + if (kind === 'thumbnail') { + await streamCachedThumbnail( + res, photo, + () => fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase), + () => streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase), + ); + return; + } await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase); return; } diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index d493b960..f65a2013 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -604,6 +604,47 @@ export async function getSynologyAssetInfo(userId: number, photoId: string, targ return success(normalized); } +export async function fetchSynologyThumbnailBytes( + userId: number, + targetUserId: number, + photoId: string, + passphrase?: string, +): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> { + const parsedId = _splitPackedSynologyId(photoId); + if (!parsedId) return { error: 'Invalid photo ID format', status: 400 }; + + const synology_credentials = _getSynologyCredentials(targetUserId); + if (!synology_credentials.success) return { error: 'Credentials error', status: 500 }; + + const sid = await _getSynologySession(targetUserId); + if (!sid.success) return { error: 'Session error', status: 500 }; + if (!sid.data) return { error: 'Session ID missing', status: 500 }; + + const params = new URLSearchParams({ + api: 'SYNO.Foto.Thumbnail', + method: 'get', + version: '2', + mode: 'download', + id: parsedId.id, + type: 'unit', + size: 'sm', + cache_key: parsedId.cacheKey, + _sid: sid.data, + }); + if (passphrase) params.append('passphrase', passphrase); + + const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); + try { + const resp = await safeFetch(url); + if (!resp.ok) return { error: 'Upstream error', status: resp.status }; + const contentType = resp.headers.get('content-type') || 'image/jpeg'; + const bytes = Buffer.from(await resp.arrayBuffer()); + return { bytes, contentType }; + } catch { + return { error: 'Proxy error', status: 502 }; + } +} + export async function streamSynologyAsset( response: Response, userId: number, diff --git a/server/src/services/memories/trekPhotoCache.ts b/server/src/services/memories/trekPhotoCache.ts new file mode 100644 index 00000000..4ee45a80 --- /dev/null +++ b/server/src/services/memories/trekPhotoCache.ts @@ -0,0 +1,91 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import crypto from 'node:crypto'; +import { Response } from 'express'; +import { db } from '../../db/database'; + +const TREK_PHOTO_DIR = path.join(__dirname, '../../../uploads/photos/trek'); +export const CACHE_TTL = 60 * 60 * 1000; // 1 hour + +const inFlight = new Map>(); + +export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string { + return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex'); +} + +function ensureDir(): void { + if (!fs.existsSync(TREK_PHOTO_DIR)) { + fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true }); + } +} + +function cachedFilePath(key: string): string { + return path.join(TREK_PHOTO_DIR, `${key}.bin`); +} + +export function getFresh(key: string): { filePath: string; contentType: string } | null { + const row = db.prepare( + 'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?' + ).get(key) as { content_type: string; fetched_at: number } | undefined; + + if (!row) return null; + + if (Date.now() - row.fetched_at >= CACHE_TTL) { + db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); + return null; + } + + const fp = cachedFilePath(key); + if (!fs.existsSync(fp)) { + db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); + return null; + } + + return { filePath: fp, contentType: row.content_type }; +} + +export async function put(key: string, bytes: Buffer, contentType: string): Promise { + ensureDir(); + const fp = cachedFilePath(key); + const tmp = fp + '.tmp'; + + await fsPromises.writeFile(tmp, bytes); + await fsPromises.rename(tmp, fp); + + db.prepare( + 'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)' + ).run(key, contentType, Date.now()); +} + +export function serveFresh(res: Response, key: string): boolean { + const entry = getFresh(key); + if (!entry) return false; + + res.set('Content-Type', entry.contentType); + res.set('Cache-Control', 'public, max-age=3600'); + res.sendFile(entry.filePath); + return true; +} + +export function getInFlight(key: string): Promise | undefined { + return inFlight.get(key); +} + +export function setInFlight(key: string, promise: Promise): void { + inFlight.set(key, promise); + promise.finally(() => inFlight.delete(key)); +} + +export function sweepExpired(): void { + const cutoff = Date.now() - CACHE_TTL * 2; + const stale = db.prepare( + 'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?' + ).all(cutoff) as { cache_key: string }[]; + + for (const row of stale) { + db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key); + const fp = cachedFilePath(row.cache_key); + if (fs.existsSync(fp)) fs.unlinkSync(fp); + } +} From 677157de1d8c3b21f3ce08e01f15a730793a25e5 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 21:02:46 +0200 Subject: [PATCH 2/2] test(journey): fix getByText assertions broken by keep-mounted tab change Tabs are now always mounted (visibility toggled via hidden class), so the same entry title can appear in multiple tab views simultaneously. Replace getByText with getAllByText for presence checks; scope the FE-086 click target to the cursor-pointer container. --- client/src/pages/JourneyDetailPage.test.tsx | 29 ++++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index d3b005cd..8b93c175 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -265,8 +265,8 @@ describe('JourneyDetailPage', () => { await renderAndWait(); const timelineBtn = screen.getByRole('button', { name: /timeline/i }); expect(timelineBtn).toBeInTheDocument(); - // Timeline entries are visible by default - expect(screen.getByText('Arrived in Rome')).toBeInTheDocument(); + // Timeline entries are visible by default (gallery also mounted but hidden, so multiple matches are expected) + expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1); }); }); @@ -274,8 +274,8 @@ describe('JourneyDetailPage', () => { describe('FE-PAGE-JOURNEYDETAIL-004: Shows entry cards with titles', () => { it('renders all entry titles in timeline view', async () => { await renderAndWait(); - expect(screen.getByText('Arrived in Rome')).toBeInTheDocument(); - expect(screen.getByText('Florence Day')).toBeInTheDocument(); + expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1); }); }); @@ -615,7 +615,7 @@ describe('JourneyDetailPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Venice Visit')).toBeInTheDocument(); + expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1); }); // Skeleton card shows "Add Entry" CTA @@ -655,10 +655,10 @@ describe('JourneyDetailPage', () => { render(); await waitFor(() => { - expect(screen.getByText('Quick stop at cafe')).toBeInTheDocument(); + expect(screen.getAllByText('Quick stop at cafe').length).toBeGreaterThanOrEqual(1); }); - expect(screen.getByText(/Cafe Roma/)).toBeInTheDocument(); + expect(screen.getAllByText(/Cafe Roma/).length).toBeGreaterThanOrEqual(1); expect(screen.getByText('Grabbed an espresso')).toBeInTheDocument(); }); }); @@ -1117,8 +1117,9 @@ describe('JourneyDetailPage', () => { // Map view renders a location list with entry titles/location names // The MapView component shows entry names in clickable location items - expect(screen.getByText('Arrived in Rome')).toBeInTheDocument(); - expect(screen.getByText('Florence Day')).toBeInTheDocument(); + // (timeline is still mounted but hidden, so multiple matches are expected) + expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1); }); }); @@ -1177,8 +1178,8 @@ describe('JourneyDetailPage', () => { expect(dayBadges.length).toBeGreaterThanOrEqual(2); // Each day group shows its entries - expect(screen.getByText('Arrived in Rome')).toBeInTheDocument(); - expect(screen.getByText('Florence Day')).toBeInTheDocument(); + expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1); }); }); @@ -1878,8 +1879,10 @@ describe('JourneyDetailPage', () => { expect(screen.getAllByTestId('journey-map').length).toBeGreaterThanOrEqual(1); }); - // Click the "Arrived in Rome" location item - const romeItem = screen.getByText('Arrived in Rome'); + // Click the "Arrived in Rome" location item in the map view's location list + // (timeline is still mounted but hidden, so find the one inside a cursor-pointer container) + const romeItems = screen.getAllByText('Arrived in Rome'); + const romeItem = romeItems.find(el => el.closest('[class*="cursor-pointer"]')) ?? romeItems[0]; await user.click(romeItem); // After clicking, the item should gain active styles (translate-x-0.5 on the container)