const Database = require('better-sqlite3'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const config = require('./config'); let db = null; function getDb() { if (db) return db; config.ensureConfigDir(); db = new Database(config.DB_PATH); db.pragma('journal_mode = WAL'); initSchema(); return db; } function initSchema() { db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, filename TEXT NOT NULL, filepath TEXT NOT NULL, sha256_hash TEXT NOT NULL, file_size INTEGER NOT NULL, mime_type TEXT NOT NULL, indexed_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id), UNIQUE(user_id, sha256_hash) ); CREATE TABLE IF NOT EXISTS albums ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id), UNIQUE(user_id, name) ); CREATE TABLE IF NOT EXISTS album_photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, album_id INTEGER NOT NULL, photo_id INTEGER NOT NULL, added_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE, FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE, UNIQUE(album_id, photo_id) ); CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id); CREATE INDEX IF NOT EXISTS idx_photos_hash ON photos(sha256_hash); CREATE INDEX IF NOT EXISTS idx_albums_user ON albums(user_id); CREATE INDEX IF NOT EXISTS idx_album_photos_album ON album_photos(album_id); `); } function ensureUser(username) { const d = getDb(); const existing = d.prepare('SELECT id FROM users WHERE username = ?').get(username); if (existing) return existing.id; const result = d.prepare('INSERT INTO users (username) VALUES (?)').run(username); return Number(result.lastInsertRowid); } function hashFile(filePath) { const buffer = fs.readFileSync(filePath); return crypto.createHash('sha256').update(buffer).digest('hex'); } function getMimeType(ext) { const map = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp', '.heic': 'image/heic', '.heif': 'image/heif', '.tiff': 'image/tiff', '.tif': 'image/tiff', '.svg': 'image/svg+xml', }; return map[ext.toLowerCase()] || 'application/octet-stream'; } const SUPPORTED_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.heif', '.tiff', '.tif', '.svg']; function indexPhotosForUser(username, photosBasePath) { const d = getDb(); const userId = ensureUser(username); const userDir = path.join(photosBasePath, username); if (!fs.existsSync(userDir)) { fs.mkdirSync(userDir, { recursive: true }); return { indexed: 0, skipped: 0, errors: 0, total: 0 }; } const files = getAllMediaFiles(userDir); let indexed = 0, skipped = 0, errors = 0; const insertStmt = d.prepare(` INSERT OR IGNORE INTO photos (user_id, filename, filepath, sha256_hash, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?) `); for (const fullPath of files) { try { const stat = fs.statSync(fullPath); const hash = hashFile(fullPath); const ext = path.extname(fullPath); const mime = getMimeType(ext); const filename = path.basename(fullPath); const result = insertStmt.run(userId, filename, fullPath, hash, stat.size, mime); if (result.changes > 0) { indexed++; } else { skipped++; } } catch (err) { errors++; } } return { indexed, skipped, errors, total: files.length }; } // Recursively find all media files in a directory function getAllMediaFiles(dir) { let results = []; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { results = results.concat(getAllMediaFiles(fullPath)); } else { const ext = path.extname(entry.name).toLowerCase(); if (SUPPORTED_EXTS.includes(ext)) { results.push(fullPath); } } } return results; } function getPhotoIndex(username) { const d = getDb(); const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username); if (!user) return []; return d.prepare(` SELECT id, filename, sha256_hash, file_size, mime_type, indexed_at FROM photos WHERE user_id = ? ORDER BY indexed_at DESC `).all(user.id); } function getPhotoById(photoId) { const d = getDb(); return d.prepare(` SELECT p.*, u.username FROM photos p JOIN users u ON p.user_id = u.id WHERE p.id = ? `).get(photoId); } function getPhotoByHash(hash) { const d = getDb(); return d.prepare(` SELECT p.*, u.username FROM photos p JOIN users u ON p.user_id = u.id WHERE p.sha256_hash = ? `).get(hash); } function getAllUsers() { const d = getDb(); return d.prepare('SELECT * FROM users ORDER BY username').all(); } function removeOrphanedEntries(username, photosBasePath) { const d = getDb(); const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username); if (!user) return 0; const photos = d.prepare('SELECT id, filepath FROM photos WHERE user_id = ?').all(user.id); let removed = 0; const deleteStmt = d.prepare('DELETE FROM photos WHERE id = ?'); for (const photo of photos) { if (!fs.existsSync(photo.filepath)) { deleteStmt.run(photo.id); removed++; } } return removed; } function deletePhoto(photoId) { const d = getDb(); const photo = getPhotoById(photoId); if (!photo) return { success: false, error: 'Photo not found' }; // Remove from disk if (fs.existsSync(photo.filepath)) { fs.unlinkSync(photo.filepath); } // Remove from album_photos first (cascade might not work with all builds) d.prepare('DELETE FROM album_photos WHERE photo_id = ?').run(photoId); d.prepare('DELETE FROM photos WHERE id = ?').run(photoId); return { success: true }; } // ─── Album operations ─── function createAlbum(username, albumName) { const d = getDb(); const userId = ensureUser(username); try { const result = d.prepare('INSERT INTO albums (user_id, name) VALUES (?, ?)').run(userId, albumName); return { success: true, albumId: Number(result.lastInsertRowid) }; } catch (err) { if (err.message.includes('UNIQUE')) { return { success: false, error: 'Album already exists' }; } throw err; } } function getAlbums(username) { const d = getDb(); const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username); if (!user) return []; const albums = d.prepare('SELECT * FROM albums WHERE user_id = ? ORDER BY created_at DESC').all(user.id); return albums.map(album => { const count = d.prepare('SELECT COUNT(*) as cnt FROM album_photos WHERE album_id = ?').get(album.id); const cover = d.prepare(` SELECT p.id, p.filename, p.sha256_hash FROM album_photos ap JOIN photos p ON ap.photo_id = p.id WHERE ap.album_id = ? ORDER BY ap.added_at DESC LIMIT 1 `).get(album.id); return { ...album, photoCount: count.cnt, coverPhoto: cover || null }; }); } function getAlbumPhotos(albumId) { const d = getDb(); return d.prepare(` SELECT p.id, p.filename, p.sha256_hash, p.file_size, p.mime_type, p.indexed_at FROM album_photos ap JOIN photos p ON ap.photo_id = p.id WHERE ap.album_id = ? ORDER BY ap.added_at DESC `).all(albumId); } function addPhotoToAlbum(albumId, photoId) { const d = getDb(); try { d.prepare('INSERT OR IGNORE INTO album_photos (album_id, photo_id) VALUES (?, ?)').run(albumId, photoId); return { success: true }; } catch (err) { return { success: false, error: err.message }; } } function removePhotoFromAlbum(albumId, photoId) { const d = getDb(); d.prepare('DELETE FROM album_photos WHERE album_id = ? AND photo_id = ?').run(albumId, photoId); return { success: true }; } function deleteAlbum(albumId) { const d = getDb(); d.prepare('DELETE FROM album_photos WHERE album_id = ?').run(albumId); d.prepare('DELETE FROM albums WHERE id = ?').run(albumId); return { success: true }; } function renameAlbum(albumId, newName) { const d = getDb(); try { d.prepare('UPDATE albums SET name = ? WHERE id = ?').run(newName, albumId); return { success: true }; } catch (err) { return { success: false, error: err.message }; } } function closeDb() { if (db) { db.close(); db = null; } } module.exports = { getDb, ensureUser, hashFile, indexPhotosForUser, getPhotoIndex, getPhotoById, getPhotoByHash, getAllUsers, removeOrphanedEntries, deletePhoto, createAlbum, getAlbums, getAlbumPhotos, addPhotoToAlbum, removePhotoFromAlbum, deleteAlbum, renameAlbum, closeDb, };