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:
1 parent
6f4e90a1c8
commit
bc2eb7307f
5 files changed
+172
-50
No files matched your search
@@ -115,11 +115,12 @@ export function fetchVideoStats() {
|
|||||||
return api('/api/videos/stats');
|
return api('/api/videos/stats');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchVideos({ from, to, city } = {}) {
|
export function fetchVideos({ from, to, city, cursor } = {}) {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
if (from) p.set('from', from);
|
if (from) p.set('from', from);
|
||||||
if (to) p.set('to', to);
|
if (to) p.set('to', to);
|
||||||
if (city) p.set('city', city);
|
if (city) p.set('city', city);
|
||||||
|
if (cursor) p.set('cursor', cursor);
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
return api(`/api/videos${qs ? '?' + qs : ''}`);
|
return api(`/api/videos${qs ? '?' + qs : ''}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ export default function MemoriesView() {
|
|||||||
{scanInfo && (
|
{scanInfo && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span style={{ fontSize: 11, color: '#444' }}>
|
<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>
|
</span>
|
||||||
{scanInfo.enabled && !scanInfo.scanning && (
|
{scanInfo.enabled && !scanInfo.scanning && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { fetchVideos } from '../api/photos.js';
|
import { fetchVideos } from '../api/photos.js';
|
||||||
|
|
||||||
// ── Grouping (mirrors Timeline.jsx) ───────────────────────────────────────────
|
// ── Grouping (mirrors Timeline.jsx) ───────────────────────────────────────────
|
||||||
@@ -47,19 +47,52 @@ function fmtSize(b) {
|
|||||||
const SHORT_THRESHOLD = 2; // seconds
|
const SHORT_THRESHOLD = 2; // seconds
|
||||||
|
|
||||||
export default function VideosView({ filters = {} }) {
|
export default function VideosView({ filters = {} }) {
|
||||||
const [videos, setVideos] = useState(null);
|
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 [playing, setPlaying] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [showShort, setShowShort] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setVideos(null);
|
setVideos([]);
|
||||||
setError(null);
|
setNextCursor(undefined);
|
||||||
fetchVideos({ from: filters.from, to: filters.to, city: filters.city })
|
setTotal(null);
|
||||||
.then(d => setVideos(d.videos))
|
loadPage(undefined, { from: filters.from, to: filters.to, city: filters.city });
|
||||||
.catch(e => setError(e.message));
|
|
||||||
}, [filters.from, filters.to, 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(() => {
|
useEffect(() => {
|
||||||
if (!playing) return;
|
if (!playing) return;
|
||||||
const onKey = (e) => { if (e.key === 'Escape') setPlaying(null); };
|
const onKey = (e) => { if (e.key === 'Escape') setPlaying(null); };
|
||||||
@@ -68,9 +101,9 @@ export default function VideosView({ filters = {} }) {
|
|||||||
}, [playing]);
|
}, [playing]);
|
||||||
|
|
||||||
if (error) return <p style={{ color: '#f87171', padding: 24, fontSize: 14 }}>{error}</p>;
|
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 (videos.length === 0) {
|
const isEmpty = !loading && videos.length === 0;
|
||||||
|
if (isEmpty) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
|
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
|
||||||
<div style={{ fontSize: 44, marginBottom: 18, opacity: 0.4 }}>🎬</div>
|
<div style={{ fontSize: 44, marginBottom: 18, opacity: 0.4 }}>🎬</div>
|
||||||
@@ -112,6 +145,9 @@ export default function VideosView({ filters = {} }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<main style={{ padding: '0 3px' }}>
|
<main style={{ padding: '0 3px' }}>
|
||||||
|
{loading && videos.length === 0 && (
|
||||||
|
<p style={{ padding: 24, color: '#555', fontSize: 13 }}>Loading…</p>
|
||||||
|
)}
|
||||||
{groups.map(group => (
|
{groups.map(group => (
|
||||||
<section key={group.key} style={{ marginBottom: 8 }}>
|
<section key={group.key} style={{ marginBottom: 8 }}>
|
||||||
|
|
||||||
@@ -140,6 +176,15 @@ export default function VideosView({ filters = {} }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
{/* ── Player overlay ── */}
|
{/* ── Player overlay ── */}
|
||||||
|
|||||||
+102
-39
@@ -26,6 +26,19 @@ if (MUSIC_DIR) {
|
|||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// ── In-memory cache (invalidated on any write) ────────────────────────────────
|
||||||
|
const cache = {
|
||||||
|
_store: {},
|
||||||
|
get(key, ttlMs) {
|
||||||
|
const e = this._store[key];
|
||||||
|
if (!e || Date.now() - e.ts > ttlMs) return null;
|
||||||
|
return e.data;
|
||||||
|
},
|
||||||
|
set(key, data) { this._store[key] = { data, ts: Date.now() }; },
|
||||||
|
del(key) { delete this._store[key]; },
|
||||||
|
invalidate() { this._store = {}; },
|
||||||
|
};
|
||||||
|
|
||||||
// ── Photo Sync state ──────────────────────────────────────────────────────────
|
// ── Photo Sync state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const syncJobs = {
|
const syncJobs = {
|
||||||
@@ -241,6 +254,10 @@ app.get('/api/ingest/stream', (req, res) => {
|
|||||||
// ── Library stats ─────────────────────────────────────────────────────────────
|
// ── Library stats ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/library/stats', (_req, res) => {
|
app.get('/api/library/stats', (_req, res) => {
|
||||||
|
const cached = cache.get('library-stats', 30_000);
|
||||||
|
if (cached) return res.json(cached);
|
||||||
|
|
||||||
|
const total = db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE deleted = 0').get().n;
|
||||||
const starred = db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE starred = 1 AND deleted = 0').get().n;
|
const starred = db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE starred = 1 AND deleted = 0').get().n;
|
||||||
const years = db.raw.prepare(
|
const years = db.raw.prepare(
|
||||||
"SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE date_taken IS NOT NULL AND deleted = 0 GROUP BY year ORDER BY year DESC"
|
"SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE date_taken IS NOT NULL AND deleted = 0 GROUP BY year ORDER BY year DESC"
|
||||||
@@ -248,14 +265,9 @@ app.get('/api/library/stats', (_req, res) => {
|
|||||||
const cities = db.raw.prepare(
|
const cities = db.raw.prepare(
|
||||||
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE place_city IS NOT NULL AND deleted = 0 GROUP BY place_city ORDER BY count DESC LIMIT 100"
|
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE place_city IS NOT NULL AND deleted = 0 GROUP BY place_city ORDER BY count DESC LIMIT 100"
|
||||||
).all();
|
).all();
|
||||||
res.json({
|
const data = { total, starred, years, cities, tags: db.listAllTags(), collections: db.listCollections() };
|
||||||
total: db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE deleted = 0').get().n,
|
cache.set('library-stats', data);
|
||||||
starred,
|
res.json(data);
|
||||||
years,
|
|
||||||
cities,
|
|
||||||
tags: db.listAllTags(),
|
|
||||||
collections: db.listCollections(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Photos ────────────────────────────────────────────────────────────────────
|
// ── Photos ────────────────────────────────────────────────────────────────────
|
||||||
@@ -348,16 +360,19 @@ app.get('/api/photos/:id', (req, res) => {
|
|||||||
|
|
||||||
app.delete('/api/photos/:id', (req, res) => {
|
app.delete('/api/photos/:id', (req, res) => {
|
||||||
db.raw.prepare('UPDATE photos SET deleted = 1 WHERE id = ?').run(parseInt(req.params.id, 10));
|
db.raw.prepare('UPDATE photos SET deleted = 1 WHERE id = ?').run(parseInt(req.params.id, 10));
|
||||||
|
cache.invalidate();
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/photos/:id/restore', (req, res) => {
|
app.post('/api/photos/:id/restore', (req, res) => {
|
||||||
db.raw.prepare('UPDATE photos SET deleted = 0 WHERE id = ?').run(parseInt(req.params.id, 10));
|
db.raw.prepare('UPDATE photos SET deleted = 0 WHERE id = ?').run(parseInt(req.params.id, 10));
|
||||||
|
cache.invalidate();
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/photos/:id/permanent', (req, res) => {
|
app.delete('/api/photos/:id/permanent', (req, res) => {
|
||||||
db.raw.prepare('DELETE FROM photos WHERE id = ?').run(parseInt(req.params.id, 10));
|
db.raw.prepare('DELETE FROM photos WHERE id = ?').run(parseInt(req.params.id, 10));
|
||||||
|
cache.invalidate();
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,6 +389,7 @@ app.patch('/api/photos/bulk', (req, res) => {
|
|||||||
if ('starred' in req.body) {
|
if ('starred' in req.body) {
|
||||||
const ph = ids.map(() => '?').join(',');
|
const ph = ids.map(() => '?').join(',');
|
||||||
db.raw.prepare(`UPDATE photos SET starred = ? WHERE id IN (${ph})`).run(req.body.starred ? 1 : 0, ...ids);
|
db.raw.prepare(`UPDATE photos SET starred = ? WHERE id IN (${ph})`).run(req.body.starred ? 1 : 0, ...ids);
|
||||||
|
cache.del('library-stats');
|
||||||
}
|
}
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
});
|
});
|
||||||
@@ -412,7 +428,9 @@ app.post('/api/collections', (req, res) => {
|
|||||||
const name = (req.body.name ?? '').trim().slice(0, 200);
|
const name = (req.body.name ?? '').trim().slice(0, 200);
|
||||||
if (!name) return badRequest(res, 'name required');
|
if (!name) return badRequest(res, 'name required');
|
||||||
try {
|
try {
|
||||||
res.status(201).json(db.createCollection(name, req.body.description));
|
const col = db.createCollection(name, req.body.description);
|
||||||
|
cache.invalidate();
|
||||||
|
res.status(201).json(col);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message.includes('UNIQUE')) return badRequest(res, 'collection already exists');
|
if (e.message.includes('UNIQUE')) return badRequest(res, 'collection already exists');
|
||||||
throw e;
|
throw e;
|
||||||
@@ -424,6 +442,7 @@ app.patch('/api/collections/:id', (req, res) => {
|
|||||||
if (!name) return badRequest(res, 'name required');
|
if (!name) return badRequest(res, 'name required');
|
||||||
try {
|
try {
|
||||||
db.raw.prepare('UPDATE collections SET name = ? WHERE id = ?').run(name, parseInt(req.params.id, 10));
|
db.raw.prepare('UPDATE collections SET name = ? WHERE id = ?').run(name, parseInt(req.params.id, 10));
|
||||||
|
cache.invalidate();
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message.includes('UNIQUE')) return badRequest(res, 'collection already exists');
|
if (e.message.includes('UNIQUE')) return badRequest(res, 'collection already exists');
|
||||||
@@ -433,62 +452,80 @@ app.patch('/api/collections/:id', (req, res) => {
|
|||||||
|
|
||||||
app.delete('/api/collections/:id', (req, res) => {
|
app.delete('/api/collections/:id', (req, res) => {
|
||||||
db.raw.prepare('DELETE FROM collections WHERE id = ?').run(parseInt(req.params.id, 10));
|
db.raw.prepare('DELETE FROM collections WHERE id = ?').run(parseInt(req.params.id, 10));
|
||||||
|
cache.invalidate();
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Albums (manual + smart auto-albums) ───────────────────────────────────────
|
// ── Albums (manual + smart auto-albums) ───────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/albums', (_req, res) => {
|
app.get('/api/albums', (_req, res) => {
|
||||||
|
const cached = cache.get('albums', 60_000);
|
||||||
|
if (cached) return res.json(cached);
|
||||||
|
|
||||||
|
// Collections: count from indexed join; cover via point-lookup on MAX(id) per collection
|
||||||
const manual = db.raw.prepare(`
|
const manual = db.raw.prepare(`
|
||||||
|
WITH cover AS (
|
||||||
|
SELECT collection_id, MAX(photo_id) AS cover_id
|
||||||
|
FROM photo_collections GROUP BY collection_id
|
||||||
|
)
|
||||||
SELECT c.id, c.name, c.description, c.created_at,
|
SELECT c.id, c.name, c.description, c.created_at,
|
||||||
COUNT(pc.photo_id) AS photo_count,
|
COUNT(pc.photo_id) AS photo_count,
|
||||||
(SELECT p.thumb_path FROM photo_collections pc2 JOIN photos p ON p.id = pc2.photo_id
|
p.thumb_path AS cover_path
|
||||||
WHERE pc2.collection_id = c.id ORDER BY p.date_taken DESC, p.id DESC LIMIT 1) AS cover_path
|
FROM collections c
|
||||||
FROM collections c LEFT JOIN photo_collections pc ON pc.collection_id = c.id
|
LEFT JOIN photo_collections pc ON pc.collection_id = c.id
|
||||||
|
LEFT JOIN cover cv ON cv.collection_id = c.id
|
||||||
|
LEFT JOIN photos p ON p.id = cv.cover_id
|
||||||
GROUP BY c.id ORDER BY c.name
|
GROUP BY c.id ORDER BY c.name
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
const PHOTO_ONLY = "deleted = 0 AND (media_type IS NULL OR media_type = 'photo')";
|
const PHOTO_ONLY = "deleted = 0 AND (media_type IS NULL OR media_type = 'photo')";
|
||||||
|
|
||||||
|
// Use GROUP BY + MAX(id) per bucket instead of window functions (much faster at scale)
|
||||||
const years = db.raw.prepare(`
|
const years = db.raw.prepare(`
|
||||||
SELECT year, count, cover_path FROM (
|
WITH agg AS (
|
||||||
SELECT strftime('%Y', date_taken) AS year,
|
SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count, MAX(id) AS cover_id
|
||||||
COUNT(*) OVER (PARTITION BY strftime('%Y', date_taken)) AS count,
|
|
||||||
thumb_path AS cover_path,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY strftime('%Y', date_taken) ORDER BY date_taken DESC, id DESC) AS rn
|
|
||||||
FROM photos WHERE date_taken IS NOT NULL AND ${PHOTO_ONLY}
|
FROM photos WHERE date_taken IS NOT NULL AND ${PHOTO_ONLY}
|
||||||
) WHERE rn = 1 ORDER BY year DESC
|
GROUP BY strftime('%Y', date_taken)
|
||||||
|
)
|
||||||
|
SELECT a.year, a.count, p.thumb_path AS cover_path
|
||||||
|
FROM agg a JOIN photos p ON p.id = a.cover_id
|
||||||
|
ORDER BY a.year DESC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
const cities = db.raw.prepare(`
|
const cities = db.raw.prepare(`
|
||||||
SELECT city, country, count, cover_path FROM (
|
WITH agg AS (
|
||||||
SELECT place_city AS city, place_country AS country,
|
SELECT place_city AS city, place_country AS country,
|
||||||
COUNT(*) OVER (PARTITION BY place_city) AS count,
|
COUNT(*) AS count, MAX(id) AS cover_id
|
||||||
thumb_path AS cover_path,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY place_city ORDER BY date_taken DESC, id DESC) AS rn
|
|
||||||
FROM photos WHERE place_city IS NOT NULL AND ${PHOTO_ONLY}
|
FROM photos WHERE place_city IS NOT NULL AND ${PHOTO_ONLY}
|
||||||
) WHERE rn = 1 ORDER BY count DESC LIMIT 50
|
GROUP BY place_city
|
||||||
|
)
|
||||||
|
SELECT a.city, a.country, a.count, p.thumb_path AS cover_path
|
||||||
|
FROM agg a JOIN photos p ON p.id = a.cover_id
|
||||||
|
ORDER BY a.count DESC LIMIT 50
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
const cameras = db.raw.prepare(`
|
const cameras = db.raw.prepare(`
|
||||||
SELECT camera, count, cover_path FROM (
|
WITH agg AS (
|
||||||
SELECT camera_model AS camera,
|
SELECT camera_model AS camera, COUNT(*) AS count, MAX(id) AS cover_id
|
||||||
COUNT(*) OVER (PARTITION BY camera_model) AS count,
|
|
||||||
thumb_path AS cover_path,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY camera_model ORDER BY date_taken DESC, id DESC) AS rn
|
|
||||||
FROM photos WHERE camera_model IS NOT NULL AND ${PHOTO_ONLY}
|
FROM photos WHERE camera_model IS NOT NULL AND ${PHOTO_ONLY}
|
||||||
) WHERE rn = 1 ORDER BY count DESC
|
GROUP BY camera_model
|
||||||
|
)
|
||||||
|
SELECT a.camera, a.count, p.thumb_path AS cover_path
|
||||||
|
FROM agg a JOIN photos p ON p.id = a.cover_id
|
||||||
|
ORDER BY a.count DESC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
const recent = [7, 30].map(days => {
|
const recent = [7, 30].map(days => {
|
||||||
const since = new Date(Date.now() - days * 864e5).toISOString();
|
const since = new Date(Date.now() - days * 864e5).toISOString();
|
||||||
const count = db.raw.prepare(`SELECT COUNT(*) AS n FROM photos WHERE date_imported >= ? AND ${PHOTO_ONLY}`).get(since).n;
|
const row = db.raw.prepare(
|
||||||
if (!count) return null;
|
`SELECT COUNT(*) AS count, MAX(id) AS cover_id FROM photos WHERE date_imported >= ? AND ${PHOTO_ONLY}`
|
||||||
const cover = db.raw.prepare(`SELECT thumb_path FROM photos WHERE date_imported >= ? AND ${PHOTO_ONLY} ORDER BY date_imported DESC LIMIT 1`).get(since);
|
).get(since);
|
||||||
return { days, label: days === 7 ? 'Last 7 days' : 'Last 30 days', count, cover_url: posterUrl(cover?.thumb_path) };
|
if (!row.count) return null;
|
||||||
|
const cover = db.raw.prepare('SELECT thumb_path FROM photos WHERE id = ?').get(row.cover_id);
|
||||||
|
return { days, label: days === 7 ? 'Last 7 days' : 'Last 30 days', count: row.count, cover_url: posterUrl(cover?.thumb_path) };
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
|
|
||||||
res.json({
|
const data = {
|
||||||
manual: manual.map(c => ({ ...c, cover_url: posterUrl(c.cover_path), cover_path: undefined })),
|
manual: manual.map(c => ({ ...c, cover_url: posterUrl(c.cover_path), cover_path: undefined })),
|
||||||
auto: {
|
auto: {
|
||||||
recent,
|
recent,
|
||||||
@@ -496,7 +533,9 @@ app.get('/api/albums', (_req, res) => {
|
|||||||
cities: cities.map(r => ({ city: r.city, country: r.country, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
cities: cities.map(r => ({ city: r.city, country: r.country, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
||||||
cameras: cameras.map(r => ({ camera: r.camera, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
cameras: cameras.map(r => ({ camera: r.camera, count: r.count, cover_url: posterUrl(r.cover_path) })),
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
cache.set('albums', data);
|
||||||
|
res.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/photos/bulk/collections', (req, res) => {
|
app.post('/api/photos/bulk/collections', (req, res) => {
|
||||||
@@ -671,17 +710,41 @@ app.get('/api/videos/stats', (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/videos', (req, res) => {
|
app.get('/api/videos', (req, res) => {
|
||||||
const { from, to, city } = req.query;
|
const { from, to, city, cursor } = req.query;
|
||||||
|
const limit = 100;
|
||||||
const conditions = ["p.media_type = 'video'", "p.deleted = 0"];
|
const conditions = ["p.media_type = 'video'", "p.deleted = 0"];
|
||||||
const params = [];
|
const params = [];
|
||||||
if (from) { conditions.push('p.date_taken >= ?'); params.push(from); }
|
if (from) { conditions.push('p.date_taken >= ?'); params.push(from); }
|
||||||
if (to) { conditions.push('p.date_taken <= ?'); params.push(to); }
|
if (to) { conditions.push('p.date_taken <= ?'); params.push(to); }
|
||||||
if (city) { conditions.push('p.place_city = ?'); params.push(city); }
|
if (city) { conditions.push('p.place_city = ?'); params.push(city); }
|
||||||
|
|
||||||
|
let cursorSql = '';
|
||||||
|
const cursorParams = [];
|
||||||
|
if (cursor) {
|
||||||
|
const pipeAt = cursor.lastIndexOf('|');
|
||||||
|
const cursorDate = cursor.slice(0, pipeAt);
|
||||||
|
const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
|
||||||
|
cursorSql = cursorDate
|
||||||
|
? `AND (p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)`
|
||||||
|
: `AND (p.date_taken IS NULL AND p.id < ?)`;
|
||||||
|
cursorDate ? cursorParams.push(cursorDate, cursorDate, cursorId) : cursorParams.push(cursorId);
|
||||||
|
}
|
||||||
|
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
const rows = db.raw.prepare(
|
const rows = db.raw.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM photos p ${where} ORDER BY p.date_taken DESC, p.id DESC`
|
`SELECT ${SELECT_COLS} FROM photos p ${where} ${cursorSql} ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`
|
||||||
).all(...params);
|
).all(...params, ...cursorParams, limit + 1);
|
||||||
res.json({ videos: rows.map(mapPhoto), total: rows.length });
|
|
||||||
|
const total = db.raw.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM photos p ${where}`
|
||||||
|
).get(...params).n;
|
||||||
|
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = rows.slice(0, limit);
|
||||||
|
const last = page[page.length - 1];
|
||||||
|
const nextCursor = hasMore && last ? `${last.date_taken ?? ''}|${last.id}` : null;
|
||||||
|
|
||||||
|
res.json({ videos: page.map(mapPhoto), nextCursor, total });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/photos/:id/video', (req, res) => {
|
app.get('/api/photos/:id/video', (req, res) => {
|
||||||
|
|||||||
@@ -126,6 +126,19 @@ export function openDb(dbPath) {
|
|||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)');
|
||||||
|
|
||||||
|
// Performance indexes for 30k+ libraries
|
||||||
|
// Composite covering index for the main library query (deleted + date_taken for sorted pagination)
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_deleted_date ON photos(deleted, date_taken DESC, id DESC)');
|
||||||
|
// Composite for stats queries
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_deleted_starred ON photos(deleted, starred)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_deleted_type ON photos(deleted, media_type)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_deleted_city ON photos(deleted, place_city)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_deleted_camera ON photos(deleted, camera_model)');
|
||||||
|
// Expression index for Memories ("on this day") — makes strftime('%m-%d') queries instant
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_photos_month_day ON photos(strftime('%m-%d', date_taken)) WHERE date_taken IS NOT NULL");
|
||||||
|
// Collections join performance
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_photo_collections_col ON photo_collections(collection_id, photo_id)');
|
||||||
|
|
||||||
// Seed default settings
|
// Seed default settings
|
||||||
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||||
for (const [k, v] of Object.entries(SETTING_DEFAULTS)) seedSetting.run(k, v);
|
for (const [k, v] of Object.entries(SETTING_DEFAULTS)) seedSetting.run(k, v);
|
||||||
|
|||||||
Reference in new issue
Block a user