Files
Media-Aggregation-and-Sorti…/ServerFiles/db.js
T

321 lines
9.0 KiB
JavaScript

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,
};