mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
321 lines
9.0 KiB
JavaScript
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,
|
|
};
|
|
|