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
This commit is contained in:
jubnl
2026-04-17 20:49:38 +02:00
parent ae4dfc48cc
commit b5b1d32b31
9 changed files with 268 additions and 16 deletions
@@ -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,