startSlideshow(idx)}
- style={{
- width: 168, height: 126, borderRadius: 5,
- overflow: 'hidden', cursor: 'pointer',
- flexShrink: 0, background: '#111',
- position: 'relative',
- }}
- onMouseEnter={e => { e.currentTarget.style.opacity = '0.78'; }}
+ style={{ width: 168, height: 126, borderRadius: 5, overflow: 'hidden', cursor: 'pointer', flexShrink: 0, background: '#111' }}
+ onMouseEnter={e => { e.currentTarget.style.opacity = '0.75'; }}
onMouseLeave={e => { e.currentTarget.style.opacity = '1'; }}
>
{photo.thumb_url
@@ -300,29 +385,23 @@ export default function MemoriesView() {
{slideshow && allPhotos.length > 0 && (() => {
const photo = allPhotos[ssIndex];
const year = new Date(photo.date_taken).getFullYear();
+ const style = MUSIC_STYLES.find(s => s.key === musicStyle);
return (
- {/* Progress dots */}
+
+ {/* Dots */}
{allPhotos.map((_, i) => (
{ setOpacity(0); setTimeout(() => { setSsIndex(i); setOpacity(1); }, 330); }}
- style={{
- width: i === ssIndex ? 20 : 6, height: 6, borderRadius: 3,
- background: i === ssIndex ? '#4d9eff' : '#2a2a2a',
- cursor: 'pointer',
- transition: 'width 0.25s, background 0.25s',
- flexShrink: 0,
- }}
+ onClick={() => { setOpacity(0); setTimeout(() => { setSsIndex(i); setOpacity(1); }, 320); }}
+ style={{ width: i === ssIndex ? 20 : 6, height: 6, borderRadius: 3, background: i === ssIndex ? '#4d9eff' : '#2a2a2a', cursor: 'pointer', transition: 'width 0.25s, background 0.25s', flexShrink: 0 }}
/>
))}
- {/* Counter */}
-
- {ssIndex + 1} / {allPhotos.length}
+
+ {style?.icon} {ssIndex + 1} / {allPhotos.length}
- {/* Mute */}
-
- {/* Exit */}
-
+
);
@@ -394,14 +443,10 @@ export default function MemoriesView() {
);
}
-// ββ Styles βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
function navBtn(side) {
return {
- position: 'absolute', top: '50%', transform: 'translateY(-50%)',
- [side]: 14,
- background: 'rgba(0,0,0,0.45)',
- border: 'none', borderRadius: 8,
+ position: 'absolute', top: '50%', transform: 'translateY(-50%)', [side]: 14,
+ background: 'rgba(0,0,0,0.45)', border: 'none', borderRadius: 8,
color: '#fff', fontSize: 36, lineHeight: 1,
width: 44, height: 68,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -410,9 +455,6 @@ function navBtn(side) {
}
const ctrlBtn = {
- background: 'transparent',
- border: '1px solid #222', borderRadius: 6,
- color: '#888', cursor: 'pointer',
- padding: '5px 11px', fontSize: 14,
- flexShrink: 0,
+ background: 'transparent', border: '1px solid #222', borderRadius: 6,
+ color: '#888', cursor: 'pointer', padding: '5px 11px', fontSize: 14, flexShrink: 0,
};
diff --git a/client/src/components/VideosView.jsx b/client/src/components/VideosView.jsx
new file mode 100644
index 0000000..33ca18a
--- /dev/null
+++ b/client/src/components/VideosView.jsx
@@ -0,0 +1,176 @@
+import { useState, useEffect, useRef } from 'react';
+import { fetchVideos } from '../api/photos.js';
+
+function fmtDuration(s) {
+ if (!s) return null;
+ const m = Math.floor(s / 60);
+ const sec = Math.floor(s % 60).toString().padStart(2, '0');
+ return `${m}:${sec}`;
+}
+
+function fmtDate(d) {
+ if (!d) return null;
+ return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+function fmtSize(b) {
+ if (!b) return null;
+ if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
+ if (b < 1073741824) return `${(b / 1048576).toFixed(1)} MB`;
+ return `${(b / 1073741824).toFixed(2)} GB`;
+}
+
+export default function VideosView() {
+ const [videos, setVideos] = useState(null);
+ const [playing, setPlaying] = useState(null);
+ const [error, setError] = useState(null);
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ fetchVideos()
+ .then(d => setVideos(d.videos))
+ .catch(e => setError(e.message));
+ }, []);
+
+ // Keyboard: Esc to close player
+ useEffect(() => {
+ if (!playing) return;
+ const onKey = (e) => { if (e.key === 'Escape') setPlaying(null); };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [playing]);
+
+ if (error) return
{error}
;
+ if (!videos) return
Loadingβ¦
;
+
+ const noVideos = videos.length === 0;
+
+ return (
+ <>
+
+
+
+ Videos
+ {videos.length > 0 && (
+
+ {videos.length.toLocaleString()}
+
+ )}
+
+
+
+ {noVideos && (
+
+
π¬
+
No videos indexed yet.
+
Add a folder containing .mp4, .mov, or .m4v files and rescan.
+
+ )}
+
+ {/* Video grid */}
+
+ {videos.map(v => (
+
setPlaying(v)}
+ style={{
+ width: 220, height: 124, // 16:9
+ position: 'relative', cursor: 'pointer',
+ borderRadius: 6, overflow: 'hidden',
+ background: '#111', flexShrink: 0,
+ }}
+ onMouseEnter={e => e.currentTarget.querySelector('.play-ring').style.opacity = '1'}
+ onMouseLeave={e => e.currentTarget.querySelector('.play-ring').style.opacity = '0.7'}
+ >
+ {/* Thumbnail */}
+ {v.thumb_url
+ ?

+ :
+ }
+
+ {/* Play button overlay */}
+
+
+ {/* Duration badge */}
+ {v.duration != null && (
+
+ {fmtDuration(v.duration)}
+
+ )}
+
+ ))}
+
+
+
+ {/* ββ Video player overlay ββ */}
+ {playing && (
+
{ if (e.target === e.currentTarget) setPlaying(null); }}
+ >
+ {/* Close */}
+
+
+ {/* Video element */}
+
+
+ {/* Info bar */}
+
+ {fmtDate(playing.date_taken) && {fmtDate(playing.date_taken)}}
+ {playing.duration && {fmtDuration(playing.duration)}}
+ {playing.width && playing.height && {playing.width} Γ {playing.height}}
+ {fmtSize(playing.filesize) && {fmtSize(playing.filesize)}}
+
+
+ )}
+ >
+ );
+}
diff --git a/server/index.js b/server/index.js
index eced631..90946c3 100644
--- a/server/index.js
+++ b/server/index.js
@@ -154,9 +154,18 @@ function mapPhoto(p) {
place_country: p.place_country ?? null,
thumb_url: posterUrl(p.thumb_path),
preview_url: posterUrl(p.preview_path),
+ media_type: p.media_type ?? 'photo',
+ duration: p.duration ?? null,
};
}
+const VIDEO_MIME = {
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.m4v': 'video/mp4',
+ '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska',
+ '.webm': 'video/webm', '.3gp': 'video/3gpp',
+};
+function videoMime(fp) { return VIDEO_MIME[path.extname(fp).toLowerCase()] ?? 'video/octet-stream'; }
+
function badRequest(res, msg) { res.status(400).json({ error: msg }); }
function notFound(res) { res.status(404).json({ error: 'not found' }); }
@@ -242,7 +251,7 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, photos: db.count() })
const SELECT_COLS = `
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported,
p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize,
- p.starred, p.place_city, p.place_country
+ p.starred, p.place_city, p.place_country, p.media_type, p.duration
`;
app.get('/api/photos', (req, res) => {
@@ -252,6 +261,10 @@ app.get('/api/photos', (req, res) => {
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred, city);
+ // Exclude videos from the photo library β they have their own tab
+ const photoOnlyClause = "(p.media_type IS NULL OR p.media_type = 'photo')";
+ const fullWhere = whereSql ? `${whereSql} AND ${photoOnlyClause}` : `WHERE ${photoOnlyClause}`;
+
let cursorSql = '';
const cursorParams = [];
if (cursor) {
@@ -259,21 +272,20 @@ app.get('/api/photos', (req, res) => {
const cursorDate = cursor.slice(0, pipeAt);
const cursorId = parseInt(cursor.slice(pipeAt + 1), 10);
if (cursorDate === '') {
- cursorSql = whereSql ? 'AND (p.date_taken IS NULL AND p.id < ?)' : 'WHERE (p.date_taken IS NULL AND p.id < ?)';
+ cursorSql = `AND (p.date_taken IS NULL AND p.id < ?)`;
cursorParams.push(cursorId);
} else {
- const clause = '(p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)';
- cursorSql = whereSql ? `AND ${clause}` : `WHERE ${clause}`;
+ cursorSql = `AND (p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)`;
cursorParams.push(cursorDate, cursorDate, cursorId);
}
}
const allParams = [...params, ...cursorParams, limit + 1];
- const sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${whereSql} ${cursorSql}
+ const sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${fullWhere} ${cursorSql}
ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`;
const rows = db.raw.prepare(sql).all(allParams);
- const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${whereSql}`;
+ const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${fullWhere}`;
const total = db.raw.prepare(countSql).get(params).n;
const hasMore = rows.length > limit;
@@ -501,6 +513,45 @@ app.post('/api/admin/clear-dismissals', (_req, res) => {
res.sendStatus(204);
});
+// ββ Videos ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+app.get('/api/videos', (_req, res) => {
+ const rows = db.raw.prepare(`
+ SELECT ${SELECT_COLS} FROM photos p
+ WHERE p.media_type = 'video'
+ ORDER BY p.date_taken DESC, p.id DESC
+ `).all();
+ res.json({ videos: rows.map(mapPhoto), total: rows.length });
+});
+
+app.get('/api/photos/:id/video', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const row = db.raw.prepare('SELECT filepath, media_type FROM photos WHERE id = ?').get(id);
+ if (!row || row.media_type !== 'video') return notFound(res);
+ if (!fs.existsSync(row.filepath)) return notFound(res);
+
+ const total = fs.statSync(row.filepath).size;
+ const mime = videoMime(row.filepath);
+ const range = req.headers.range;
+
+ if (!range) {
+ res.writeHead(200, { 'Content-Length': total, 'Content-Type': mime, 'Accept-Ranges': 'bytes' });
+ fs.createReadStream(row.filepath).pipe(res);
+ return;
+ }
+
+ const [s, e] = range.replace(/bytes=/, '').split('-');
+ const start = parseInt(s, 10);
+ const end = e ? parseInt(e, 10) : Math.min(start + 4 * 1024 * 1024 - 1, total - 1);
+ res.writeHead(206, {
+ 'Content-Range': `bytes ${start}-${end}/${total}`,
+ 'Accept-Ranges': 'bytes',
+ 'Content-Length': end - start + 1,
+ 'Content-Type': mime,
+ });
+ fs.createReadStream(row.filepath, { start, end }).pipe(res);
+});
+
// ββ Memories (photos on this day) βββββββββββββββββββββββββββββββββββββββββββββ
app.get('/api/memories/today', (req, res) => {
diff --git a/worker/db.js b/worker/db.js
index 51d2e72..eb13bb0 100644
--- a/worker/db.js
+++ b/worker/db.js
@@ -113,8 +113,11 @@ export function openDb(dbPath) {
try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN place_city TEXT'); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN place_country TEXT'); } catch {}
- db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
- db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
+ try { db.exec("ALTER TABLE photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'photo'"); } catch {}
+ try { db.exec('ALTER TABLE photos ADD COLUMN duration REAL'); } catch {}
+ db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
+ 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)');
// Seed default settings
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
@@ -125,11 +128,11 @@ export function openDb(dbPath) {
INSERT OR IGNORE INTO photos
(filepath, thumb_path, preview_path, date_taken, date_imported,
camera_model, gps_lat, gps_lon, phash, width, height, filesize,
- place_city, place_country)
+ place_city, place_country, media_type, duration)
VALUES
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize,
- @place_city, @place_country)
+ @place_city, @place_country, @media_type, @duration)
`),
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
diff --git a/worker/ingest.js b/worker/ingest.js
index 025e275..8fb0bd1 100644
--- a/worker/ingest.js
+++ b/worker/ingest.js
@@ -1,21 +1,36 @@
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
+import { execFileSync } from 'child_process';
import sharp from 'sharp';
import exifr from 'exifr';
import { computeDHash } from './hash.js';
import { reverseGeocode } from './geocode.js';
-const SUPPORTED = new Set([
+const PHOTO_EXTS = new Set([
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
'.tiff', '.tif', '.gif', '.avif',
]);
+const VIDEO_EXTS = new Set([
+ '.mp4', '.mov', '.m4v', '.avi', '.mkv', '.webm', '.3gp',
+]);
+
export function isSupported(filepath) {
- return SUPPORTED.has(path.extname(filepath).toLowerCase());
+ const ext = path.extname(filepath).toLowerCase();
+ return PHOTO_EXTS.has(ext) || VIDEO_EXTS.has(ext);
}
export async function ingestFile(filepath, db, posterDir, settings = {}) {
+ if (VIDEO_EXTS.has(path.extname(filepath).toLowerCase())) {
+ return ingestVideo(filepath, db, posterDir, settings);
+ }
+ return ingestPhoto(filepath, db, posterDir, settings);
+}
+
+// ββ Photo ingest βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+async function ingestPhoto(filepath, db, posterDir, settings) {
if (db.hasPhoto(filepath)) return;
const thumbSize = settings.thumb_size ?? 400;
@@ -89,11 +104,125 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
filesize: stat.size,
place_city: placeCity,
place_country: placeCountry,
+ media_type: 'photo',
+ duration: null,
});
console.log(`[ingest] ${path.basename(filepath)}`);
}
+// ββ Video ingest βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+async function ingestVideo(filepath, db, posterDir, settings) {
+ if (db.hasPhoto(filepath)) return;
+
+ const thumbSize = settings.thumb_size ?? 400;
+ const thumbQuality = settings.thumb_quality ?? 80;
+ const previewSize = settings.preview_size ?? 1600;
+ const previewQuality = settings.preview_quality ?? 85;
+
+ const stat = fs.statSync(filepath);
+
+ // 1. ffprobe β dimensions, duration, creation time
+ let width = null, height = null, duration = null, dateTaken = null;
+ try {
+ const out = execFileSync('ffprobe', [
+ '-v', 'quiet', '-print_format', 'json',
+ '-show_streams', '-show_format', filepath,
+ ], { timeout: 15000, encoding: 'utf8' });
+ const probe = JSON.parse(out);
+ const vs = probe.streams?.find(s => s.codec_type === 'video');
+ width = vs?.width ?? null;
+ height = vs?.height ?? null;
+ duration = parseFloat(probe.format?.duration ?? '0') || null;
+ const ct = probe.format?.tags?.creation_time ?? probe.format?.tags?.['com.apple.quicktime.creationdate'];
+ if (ct) dateTaken = new Date(ct).toISOString();
+ } catch (e) {
+ console.error(`[ingest] ffprobe failed ${path.basename(filepath)}: ${e.message}`);
+ return;
+ }
+
+ // 2. exifr β GPS + more accurate date (best-effort)
+ let gpsLat = null, gpsLon = null;
+ try {
+ const exif = await exifr.parse(filepath, { gps: true, pick: ['DateTimeOriginal'] }) ?? {};
+ if (exif.DateTimeOriginal) dateTaken = new Date(exif.DateTimeOriginal).toISOString();
+ if (exif.latitude != null) { gpsLat = exif.latitude; gpsLon = exif.longitude; }
+ } catch {}
+
+ let placeCity = null, placeCountry = null;
+ if (gpsLat !== null) {
+ const place = await reverseGeocode(gpsLat, gpsLon);
+ placeCity = place?.city ?? null;
+ placeCountry = place?.country ?? null;
+ }
+
+ const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
+ fs.mkdirSync(dir, { recursive: true });
+
+ // 3. Extract a frame with ffmpeg at 10% of duration (capped at 3 s)
+ const seekSec = duration ? Math.min(duration * 0.1, 3) : 0;
+ const tmpFrame = path.join(dir, `.vtmp_${crypto.randomBytes(4).toString('hex')}.jpg`);
+ try {
+ execFileSync('ffmpeg', [
+ '-ss', String(seekSec.toFixed(3)),
+ '-i', filepath,
+ '-vframes', '1', '-q:v', '2', '-y', tmpFrame,
+ ], { timeout: 30000, stdio: 'pipe' });
+ } catch {
+ try {
+ // Fallback: first frame
+ execFileSync('ffmpeg', [
+ '-i', filepath, '-vframes', '1', '-q:v', '2', '-y', tmpFrame,
+ ], { timeout: 30000, stdio: 'pipe' });
+ } catch (e2) {
+ console.error(`[ingest] video thumb failed ${path.basename(filepath)}: ${e2.message}`);
+ return;
+ }
+ }
+
+ // 4. Resize extracted frame with sharp (same pipeline as photos)
+ try {
+ await sharp(tmpFrame, { failOn: 'none' })
+ .resize(thumbSize, thumbSize, { fit: 'inside', withoutEnlargement: true })
+ .jpeg({ quality: thumbQuality })
+ .toFile(thumb);
+
+ await sharp(tmpFrame, { failOn: 'none' })
+ .resize(previewSize, previewSize, { fit: 'inside', withoutEnlargement: true })
+ .jpeg({ quality: previewQuality })
+ .toFile(preview);
+ } catch (err) {
+ console.error(`[ingest] video poster failed ${path.basename(filepath)}: ${err.message}`);
+ try { fs.unlinkSync(tmpFrame); } catch {}
+ return;
+ }
+ try { fs.unlinkSync(tmpFrame); } catch {}
+
+ db.insertPhoto({
+ filepath,
+ thumb_path: thumb,
+ preview_path: preview,
+ date_taken: dateTaken,
+ date_imported: new Date().toISOString(),
+ camera_model: null,
+ gps_lat: gpsLat,
+ gps_lon: gpsLon,
+ phash: null,
+ width,
+ height,
+ filesize: stat.size,
+ place_city: placeCity,
+ place_country: placeCountry,
+ media_type: 'video',
+ duration,
+ });
+
+ console.log(`[ingest] video ${path.basename(filepath)}`);
+}
+
+// ββ Shared helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
function posterPaths(filepath, posterDir, date) {
const id = crypto.createHash('sha1').update(filepath).digest('hex').slice(0, 10);
const stem = path.basename(filepath, path.extname(filepath));