perf: indexes, query rewrites, caching — fix slow load at 30k+ photos

- Expression index on strftime('%m-%d', date_taken) makes Memories instant
- Composite indexes: (deleted, date_taken), (deleted, starred), (deleted, media_type),
  (deleted, place_city), (deleted, camera_model), (collection_id, photo_id)
- Albums: replace window-function queries with GROUP BY + MAX(id) CTE — avoids
  materialising 30k rows × 3 times just to pick cover photos
- Library stats + Albums: 30s/60s in-memory cache, invalidated on any write
- Videos: cursor-based pagination (100/page) with IntersectionObserver infinite scroll
- Fix build error: mixed ?? and || operators in MemoriesView track count display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 15:34:10 -04:00
1 parent 6f4e90a1c8
commit bc2eb7307f
5 files changed
+180 -58

No files matched your search

+5 -4
View File
@@ -115,11 +115,12 @@ export function fetchVideoStats() {
return api('/api/videos/stats');
}
export function fetchVideos({ from, to, city } = {}) {
export function fetchVideos({ from, to, city, cursor } = {}) {
const p = new URLSearchParams();
if (from) p.set('from', from);
if (to) p.set('to', to);
if (city) p.set('city', city);
if (from) p.set('from', from);
if (to) p.set('to', to);
if (city) p.set('city', city);
if (cursor) p.set('cursor', cursor);
const qs = p.toString();
return api(`/api/videos${qs ? '?' + qs : ''}`);
}
+1 -1
View File
@@ -347,7 +347,7 @@ export default function MemoriesView() {
{scanInfo && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, color: '#444' }}>
{scanInfo.scanning ? '⟳ Scanning music…' : scanInfo.enabled ? `${(scanInfo.total ?? tracks.length || scanInfo.count).toLocaleString()} tracks` : ''}
{scanInfo.scanning ? '⟳ Scanning music…' : scanInfo.enabled ? `${scanInfo.count.toLocaleString()} tracks` : ''}
</span>
{scanInfo.enabled && !scanInfo.scanning && (
<button
+58 -13
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchVideos } from '../api/photos.js';
// ── Grouping (mirrors Timeline.jsx) ───────────────────────────────────────────
@@ -47,19 +47,52 @@ function fmtSize(b) {
const SHORT_THRESHOLD = 2; // seconds
export default function VideosView({ filters = {} }) {
const [videos, setVideos] = useState(null);
const [playing, setPlaying] = useState(null);
const [error, setError] = useState(null);
const [showShort, setShowShort] = useState(false);
const [videos, setVideos] = useState([]);
const [nextCursor, setNextCursor] = useState(undefined);
const [total, setTotal] = useState(null);
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(null);
const [error, setError] = useState(null);
const [showShort, setShowShort] = useState(false);
const loadingRef = useRef(false);
const sentinelRef = useRef(null);
const loadPage = useCallback(async (cursor, activeFilters) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const d = await fetchVideos({ ...activeFilters, cursor });
setVideos(prev => cursor ? [...prev, ...d.videos] : d.videos);
setNextCursor(d.nextCursor ?? null);
setTotal(d.total);
} catch (e) {
setError(e.message);
}
loadingRef.current = false;
setLoading(false);
}, []);
useEffect(() => {
setVideos(null);
setError(null);
fetchVideos({ from: filters.from, to: filters.to, city: filters.city })
.then(d => setVideos(d.videos))
.catch(e => setError(e.message));
setVideos([]);
setNextCursor(undefined);
setTotal(null);
loadPage(undefined, { from: filters.from, to: filters.to, city: filters.city });
}, [filters.from, filters.to, filters.city]);
useEffect(() => {
if (!sentinelRef.current || !nextCursor) return;
const el = sentinelRef.current;
const cur = nextCursor;
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) loadPage(cur, { from: filters.from, to: filters.to, city: filters.city }); },
{ rootMargin: '600px' }
);
obs.observe(el);
return () => obs.disconnect();
}, [nextCursor, filters.from, filters.to, filters.city]);
useEffect(() => {
if (!playing) return;
const onKey = (e) => { if (e.key === 'Escape') setPlaying(null); };
@@ -67,10 +100,10 @@ export default function VideosView({ filters = {} }) {
return () => window.removeEventListener('keydown', onKey);
}, [playing]);
if (error) return <p style={{ color: '#f87171', padding: 24, fontSize: 14 }}>{error}</p>;
if (!videos) return <p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading…</p>;
if (error) return <p style={{ color: '#f87171', padding: 24, fontSize: 14 }}>{error}</p>;
if (videos.length === 0) {
const isEmpty = !loading && videos.length === 0;
if (isEmpty) {
return (
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
<div style={{ fontSize: 44, marginBottom: 18, opacity: 0.4 }}>🎬</div>
@@ -112,6 +145,9 @@ export default function VideosView({ filters = {} }) {
)}
<main style={{ padding: '0 3px' }}>
{loading && videos.length === 0 && (
<p style={{ padding: 24, color: '#555', fontSize: 13 }}>Loading…</p>
)}
{groups.map(group => (
<section key={group.key} style={{ marginBottom: 8 }}>
@@ -140,6 +176,15 @@ export default function VideosView({ filters = {} }) {
</div>
</section>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{loading && videos.length > 0 && (
<p style={{ textAlign: 'center', padding: 24, color: '#444', fontSize: 13 }}>Loading…</p>
)}
{nextCursor === null && videos.length > 0 && !loading && (
<p style={{ textAlign: 'center', padding: 24, color: '#555', fontSize: 12 }}>
— {total?.toLocaleString()} video{total === 1 ? '' : 's'} —
</p>
)}
</main>
{/* ── Player overlay ── */}