feat: video support + Memories music choices
Videos: - Worker ingests .mp4 .mov .m4v .avi .mkv .webm .3gp via ffmpeg/ffprobe (frame extract → sharp resize, same thumbnail pipeline as photos) - DB: media_type + duration columns with migration - GET /api/videos returns all indexed videos - GET /api/photos/:id/video streams original file with Range support for browser seek/scrub - VideosView: 16:9 thumbnail grid, play-button overlay, duration badge - Full-screen player: native <video controls>, ESC to close - Library tab now excludes videos (dedicated Videos tab) - Dockerfile: adds ffmpeg to runtime stage Memories music choices: - Three styles: ⚡ Upbeat (G-major, default), 🌙 Dreamy (A-minor), 🎬 Cinematic (D-minor, dual-oscillator richness) - Shared Web Audio engine (reverb, LPF, pad + melody) - Style picker in header; active style icon shown in slideshow bar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ed18f616f
commit
6b4bd88943
8 files changed
+608
-193
No files matched your search
+57
-6
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user