diff --git a/Dockerfile b/Dockerfile index a7f1f3b..3718aa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN npm run build # ── Stage 2: runtime ───────────────────────────────────────────────────────── FROM node:22-alpine -RUN apk add --no-cache python3 py3-pip make g++ && \ +RUN apk add --no-cache python3 py3-pip make g++ ffmpeg && \ pip3 install icloudpd --break-system-packages WORKDIR /app diff --git a/client/src/App.jsx b/client/src/App.jsx index a47dca1..6aeda4b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -10,6 +10,7 @@ import SettingsPanel from './components/SettingsPanel.jsx'; import { lazy, Suspense } from 'react'; const MapView = lazy(() => import('./components/MapView.jsx')); const MemoriesView = lazy(() => import('./components/MemoriesView.jsx')); +const VideosView = lazy(() => import('./components/VideosView.jsx')); const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }; const SIDEBAR_W = 210; @@ -126,6 +127,7 @@ export default function App() { )} + setView('videos')}>Videos setView('memories')}>Memories setView('map')}>Map setView('duplicates')}> @@ -216,6 +218,13 @@ export default function App() { )} + {/* Videos */} + {view === 'videos' && ( + Loading…

}> + +
+ )} + {/* Memories */} {view === 'memories' && ( Loading…

}> diff --git a/client/src/api/photos.js b/client/src/api/photos.js index 4187fbc..c8a4cb8 100644 --- a/client/src/api/photos.js +++ b/client/src/api/photos.js @@ -109,6 +109,11 @@ export function triggerSync(source) { return api(`/api/sync/${source}`, { method: 'POST' }); } +// Videos +export function fetchVideos() { + return api('/api/videos'); +} + // Memories export function fetchMemories() { const d = new Date(); diff --git a/client/src/components/MemoriesView.jsx b/client/src/components/MemoriesView.jsx index 951d3f1..9971f1a 100644 --- a/client/src/components/MemoriesView.jsx +++ b/client/src/components/MemoriesView.jsx @@ -1,99 +1,169 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { fetchMemories } from '../api/photos.js'; -// ── Music ────────────────────────────────────────────────────────────────────── +// ── Music engine ─────────────────────────────────────────────────────────────── -function createAmbientMusic(ctx) { - const now = ctx.currentTime; +function makeReverb(ctx, seconds) { + const conv = ctx.createConvolver(); + const sr = ctx.sampleRate; + const buf = ctx.createBuffer(2, sr * seconds, sr); + for (let ch = 0; ch < 2; ch++) { + const d = buf.getChannelData(ch); + for (let i = 0; i < d.length; i++) + d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.6); + } + conv.buffer = buf; + return conv; +} +function makeChain(ctx, { reverbSec, lpFreq, wet, dry }) { const master = ctx.createGain(); + master.connect(ctx.destination); + const reverb = makeReverb(ctx, reverbSec); + const wg = ctx.createGain(); wg.gain.value = wet; + const dg = ctx.createGain(); dg.gain.value = dry; + reverb.connect(wg); wg.connect(master); dg.connect(master); + const lpf = ctx.createBiquadFilter(); + lpf.type = 'lowpass'; lpf.frequency.value = lpFreq; lpf.Q.value = 0.4; + lpf.connect(reverb); lpf.connect(dg); + return { master, lpf }; +} + +function runMelody(ctx, { scale, spacing, dur, gain, dest, delay }) { + let cancelled = false, t = ctx.currentTime + delay, idx = 0; + function next() { + if (cancelled) return; + const osc = ctx.createOscillator(), env = ctx.createGain(); + osc.type = 'sine'; osc.frequency.value = scale[idx++ % scale.length]; + env.gain.setValueAtTime(0, t); + env.gain.linearRampToValueAtTime(gain, t + 0.05); + env.gain.setValueAtTime(gain, t + dur * 0.5); + env.gain.exponentialRampToValueAtTime(0.001, t + dur); + osc.connect(env); env.connect(dest); + osc.start(t); osc.stop(t + dur); + t += spacing; + setTimeout(next, Math.max(0, (t - ctx.currentTime - 0.6) * 1000)); + } + next(); + return () => { cancelled = true; }; +} + +function runPluck(ctx, { scale, spacing, dur, gain, dest, delay }) { + // Shorter, punchier envelope for upbeat feel + let cancelled = false, t = ctx.currentTime + delay, idx = 0; + function next() { + if (cancelled) return; + const osc = ctx.createOscillator(), env = ctx.createGain(); + osc.type = 'triangle'; osc.frequency.value = scale[idx++ % scale.length]; + env.gain.setValueAtTime(0, t); + env.gain.linearRampToValueAtTime(gain, t + 0.02); + env.gain.exponentialRampToValueAtTime(0.001, t + dur); + osc.connect(env); env.connect(dest); + osc.start(t); osc.stop(t + dur + 0.05); + t += spacing; + setTimeout(next, Math.max(0, (t - ctx.currentTime - 0.6) * 1000)); + } + next(); + return () => { cancelled = true; }; +} + +// ── Style: Upbeat (G major, bright, bouncy) ──────────────────────────────────── +function createUpbeat(ctx) { + const now = ctx.currentTime; + const { master, lpf } = makeChain(ctx, { reverbSec: 2, lpFreq: 3200, wet: 0.4, dry: 0.6 }); + master.gain.setValueAtTime(0, now); + master.gain.linearRampToValueAtTime(0.45, now + 1.5); + + // G major pad: G3, B3, D4, G4 + [196, 246.94, 293.66, 392].forEach((freq, i) => { + const osc = ctx.createOscillator(), env = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + env.gain.setValueAtTime(0, now); + env.gain.linearRampToValueAtTime(0.055 / (1 + i * 0.22), now + 0.8 + i * 0.35); + osc.connect(env); env.connect(lpf); + const lfo = ctx.createOscillator(), lfoG = ctx.createGain(); + lfo.frequency.value = 0.18 + i * 0.06; lfoG.gain.value = freq * 0.0025; + lfo.connect(lfoG); lfoG.connect(osc.frequency); lfo.start(now); osc.start(now); + }); + + // G major pentatonic: G3, A3, B3, D4, E4, G4, A4, B4 + const scale = [293.66, 329.63, 392, 440, 392, 329.63, 293.66, 246.94, + 293.66, 392, 440, 493.88, 440, 392, 329.63, 246.94]; + const cancel = runPluck(ctx, { scale, spacing: 0.85, dur: 0.65, gain: 0.11, dest: lpf, delay: 1.5 }); + + return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.8); } }; +} + +// ── Style: Dreamy (A minor, slow, nostalgic) ──────────────────────────────────── +function createDreamy(ctx) { + const now = ctx.currentTime; + const { master, lpf } = makeChain(ctx, { reverbSec: 4, lpFreq: 1900, wet: 0.65, dry: 0.35 }); master.gain.setValueAtTime(0, now); master.gain.linearRampToValueAtTime(0.38, now + 4); - master.connect(ctx.destination); - // Reverb convolver (4-second synthetic impulse) - const convolver = ctx.createConvolver(); - const sr = ctx.sampleRate; - const impulse = ctx.createBuffer(2, sr * 4, sr); - for (let ch = 0; ch < 2; ch++) { - const d = impulse.getChannelData(ch); - for (let i = 0; i < d.length; i++) { - d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.6); - } - } - convolver.buffer = impulse; - - const wet = ctx.createGain(); wet.gain.value = 0.65; - const dry = ctx.createGain(); dry.gain.value = 0.35; - convolver.connect(wet); wet.connect(master); - dry.connect(master); - - // Warmth filter - const lpf = ctx.createBiquadFilter(); - lpf.type = 'lowpass'; - lpf.frequency.value = 1900; - lpf.Q.value = 0.4; - lpf.connect(convolver); - lpf.connect(dry); - - // Pad: A minor chord — A2, E3, A3, C4, E4, G4 + // A minor pad: A2, E3, A3, C4, E4, G4 [110, 164.81, 220, 261.63, 329.63, 392].forEach((freq, i) => { - const osc = ctx.createOscillator(); - const env = ctx.createGain(); + const osc = ctx.createOscillator(), env = ctx.createGain(); osc.type = i < 2 ? 'sine' : 'triangle'; osc.frequency.value = freq + (Math.random() - 0.5) * 0.25; env.gain.setValueAtTime(0, now); env.gain.linearRampToValueAtTime(0.052 / (1 + i * 0.28), now + 1.5 + i * 0.65); - osc.connect(env); - env.connect(lpf); - - // Slow vibrato for shimmer - const lfo = ctx.createOscillator(); - const lfoG = ctx.createGain(); - lfo.frequency.value = 0.07 + i * 0.02; - lfoG.gain.value = freq * 0.0018; - lfo.connect(lfoG); lfoG.connect(osc.frequency); - lfo.start(now); osc.start(now); + osc.connect(env); env.connect(lpf); + const lfo = ctx.createOscillator(), lfoG = ctx.createGain(); + lfo.frequency.value = 0.07 + i * 0.02; lfoG.gain.value = freq * 0.0018; + lfo.connect(lfoG); lfoG.connect(osc.frequency); lfo.start(now); osc.start(now); }); - // Melody: A pentatonic minor (A3, C4, D4, E4, G4, A4) - const scale = [329.63, 293.66, 261.63, 220, 261.63, 293.66, 329.63, 392, 440, 392, 329.63, 261.63, 293.66, 329.63]; - let cancelled = false; - let noteTime = now + 5.5; - let noteIdx = 0; + // A pentatonic minor melody + const scale = [329.63, 293.66, 261.63, 220, 261.63, 293.66, 329.63, + 392, 440, 392, 329.63, 261.63, 293.66, 329.63]; + const cancel = runMelody(ctx, { scale, spacing: 2.3, dur: 1.9, gain: 0.1, dest: lpf, delay: 5.5 }); - function playNote() { - if (cancelled) return; - const freq = scale[noteIdx % scale.length]; - noteIdx++; - const osc = ctx.createOscillator(); - const env = ctx.createGain(); - osc.type = 'sine'; - osc.frequency.value = freq; - const dur = 1.9; - env.gain.setValueAtTime(0, noteTime); - env.gain.linearRampToValueAtTime(0.1, noteTime + 0.06); - env.gain.setValueAtTime(0.1, noteTime + dur * 0.45); - env.gain.exponentialRampToValueAtTime(0.001, noteTime + dur); - osc.connect(env); env.connect(convolver); - osc.start(noteTime); osc.stop(noteTime + dur); - noteTime += 2.3; - const msUntil = (noteTime - ctx.currentTime - 0.6) * 1000; - setTimeout(playNote, Math.max(0, msUntil)); - } - playNote(); + return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5); } }; +} - return { - master, - stop() { - cancelled = true; - master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5); - }, - }; +// ── Style: Cinematic (D minor, sweeping, dramatic) ───────────────────────────── +function createCinematic(ctx) { + const now = ctx.currentTime; + const { master, lpf } = makeChain(ctx, { reverbSec: 5, lpFreq: 1500, wet: 0.75, dry: 0.25 }); + master.gain.setValueAtTime(0, now); + master.gain.linearRampToValueAtTime(0.42, now + 5); + + // D minor pad: D3, F3, A3, C4, F4 — dual oscillators slightly detuned for richness + [[146.83, 0.07], [174.61, 0.055], [220, 0.05], [261.63, 0.04], [349.23, 0.03]].forEach(([freq, g], i) => { + [-1, 1].forEach(side => { + const osc = ctx.createOscillator(), env = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq * (1 + side * 0.0015); + env.gain.setValueAtTime(0, now); + env.gain.linearRampToValueAtTime(g, now + 3 + i * 0.9); + osc.connect(env); env.connect(lpf); osc.start(now); + }); + }); + + // D natural minor melody — sweeping and slow + const scale = [220, 261.63, 293.66, 261.63, 220, 196, 174.61, + 196, 220, 293.66, 261.63, 220, 196, 174.61, 196, 220]; + const cancel = runMelody(ctx, { scale, spacing: 2.8, dur: 2.4, gain: 0.09, dest: lpf, delay: 6.5 }); + + return { master, stop() { cancel(); master.gain.linearRampToValueAtTime(0, ctx.currentTime + 3); } }; +} + +function createMusic(ctx, style) { + if (style === 'dreamy') return createDreamy(ctx); + if (style === 'cinematic') return createCinematic(ctx); + return createUpbeat(ctx); } // ── Helpers ──────────────────────────────────────────────────────────────────── +const MUSIC_STYLES = [ + { key: 'upbeat', label: 'Upbeat', icon: '⚡' }, + { key: 'dreamy', label: 'Dreamy', icon: '🌙' }, + { key: 'cinematic', label: 'Cinematic', icon: '🎬' }, +]; + function groupByYear(photos) { const map = {}; for (const p of photos) { @@ -111,20 +181,20 @@ function yearsAgo(year) { return n === 1 ? '1 year ago' : `${n} years ago`; } -function localDate() { - const d = new Date(); - return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' }); +function todayLabel() { + return new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' }); } // ── Component ────────────────────────────────────────────────────────────────── export default function MemoriesView() { - const [groups, setGroups] = useState(null); - const [error, setError] = useState(null); - const [slideshow, setSlideshow] = useState(false); - const [ssIndex, setSsIndex] = useState(0); - const [opacity, setOpacity] = useState(1); - const [muted, setMuted] = useState(false); + const [groups, setGroups] = useState(null); + const [error, setError] = useState(null); + const [musicStyle, setMusicStyle] = useState('upbeat'); + const [slideshow, setSlideshow] = useState(false); + const [ssIndex, setSsIndex] = useState(0); + const [opacity, setOpacity] = useState(1); + const [muted, setMuted] = useState(false); const audioCtxRef = useRef(null); const musicRef = useRef(null); @@ -142,18 +212,18 @@ export default function MemoriesView() { // ── Music ── - function startMusic() { + function startMusic(style) { if (audioCtxRef.current) return; const ctx = new (window.AudioContext || window.webkitAudioContext)(); audioCtxRef.current = ctx; - musicRef.current = createAmbientMusic(ctx); + musicRef.current = createMusic(ctx, style); } function stopMusic() { musicRef.current?.stop(); const ctx = audioCtxRef.current; if (ctx) { - setTimeout(() => { try { ctx.close(); } catch {} }, 3000); + setTimeout(() => { try { ctx.close(); } catch {} }, 3500); audioCtxRef.current = null; musicRef.current = null; } @@ -163,8 +233,7 @@ export default function MemoriesView() { if (!musicRef.current || !audioCtxRef.current) return; const next = !muted; setMuted(next); - const g = musicRef.current.master.gain; - g.linearRampToValueAtTime(next ? 0 : 0.38, audioCtxRef.current.currentTime + 0.6); + musicRef.current.master.gain.linearRampToValueAtTime(next ? 0 : 0.42, audioCtxRef.current.currentTime + 0.5); } // ── Slideshow ── @@ -174,14 +243,12 @@ export default function MemoriesView() { setTimeout(() => { setSsIndex(i => (i + dir + allPhotos.length) % allPhotos.length); setOpacity(1); - }, 330); + }, 320); }, [allPhotos.length]); - function startSlideshow(startIndex) { - setSsIndex(startIndex); - setOpacity(1); - setSlideshow(true); - startMusic(); + function startSlideshow(idx) { + setSsIndex(idx); setOpacity(1); setSlideshow(true); + startMusic(musicStyle); } function stopSlideshow() { @@ -191,14 +258,12 @@ export default function MemoriesView() { setMuted(false); } - // Auto-advance useEffect(() => { if (!slideshow) return; timerRef.current = setInterval(() => advance(1), 5000); return () => clearInterval(timerRef.current); }, [slideshow, advance]); - // Keyboard useEffect(() => { if (!slideshow) return; function onKey(e) { @@ -215,16 +280,16 @@ export default function MemoriesView() { if (error) return

{error}

; if (!groups) return

Loading…

; - const noPhotos = allPhotos.length === 0; - const dateLabel = localDate(); + const noPhotos = allPhotos.length === 0; + const dateLabel = todayLabel(); return ( <> {/* ── Main view ── */}
- {/* Header row */} -
+ {/* Header */} +

{dateLabel} @@ -235,19 +300,44 @@ export default function MemoriesView() { : `${allPhotos.length} photo${allPhotos.length !== 1 ? 's' : ''} across ${groups.length} year${groups.length !== 1 ? 's' : ''}`}

+ {!noPhotos && ( - +
+ {/* Music style picker */} +
+ {MUSIC_STYLES.map(s => ( + + ))} +
+ + {/* Play button */} + +
)}
@@ -260,7 +350,7 @@ export default function MemoriesView() {
)} - {/* Year sections */} + {/* Year groups */} {groups.map(({ year, photos: yPhotos }) => (
@@ -275,13 +365,8 @@ export default function MemoriesView() {
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 (
- {/* Photo area */} + {/* Photo */}
{/* Year badge */}
{yearsAgo(year)}
- {/* Prev */} - - - {/* Next */} - + +
{/* Bottom bar */} -
- {/* 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 */} +
+ )} + + ); +} 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));